mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove 158 low-value tests (#39639)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -584,7 +584,6 @@
|
||||
3 test-no-wall-clock:src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts
|
||||
4 test-no-wall-clock:src/main/github/pr-refresh-queue-growth-bound.test.ts
|
||||
2 test-no-wall-clock:src/main/grok/grok-hook-stdin-no-eof.test.ts
|
||||
1 test-no-wall-clock:src/main/ipc/ai-vault/ai-vault.test.ts
|
||||
3 test-no-wall-clock:src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts
|
||||
1 test-no-wall-clock:src/main/ipc/terminal-render-desync-evidence.test.ts
|
||||
5 test-no-wall-clock:src/main/ipc/worktrees-orphan-directory-cleanup.test.ts
|
||||
@@ -806,7 +805,6 @@
|
||||
1 test-timeout-needs-reason:src/main/codex/structured/launch/codex-structured-real-cli-command-env.test.ts
|
||||
1 test-timeout-needs-reason:src/main/codex/structured/launch/codex-structured-real-cli-permissions.test.ts
|
||||
1 test-timeout-needs-reason:src/main/codex/structured/launch/codex-structured-real-cli-visuals.test.ts
|
||||
1 test-timeout-needs-reason:src/main/codex/trust/toml/codex-trust-config-concurrent-launch.test.ts
|
||||
1 test-timeout-needs-reason:src/main/cursor/hook-service.test.ts
|
||||
1 test-timeout-needs-reason:src/main/daemon/executable-host/linux/testing/daemon-appimage-copy-host.electron.test.ts
|
||||
2 test-timeout-needs-reason:src/main/daemon/health/testing/issue-6814-daemon-failure-classification.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -952,7 +952,6 @@
|
||||
"src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation-adapters.test.ts": 252,
|
||||
"src/main/browser/client-pages/renderer/browser-client-page-renderer-bridge.test.ts": 163,
|
||||
"src/main/browser/client-pages/lifecycle/browser-client-page-upload-routing.test.ts": 303,
|
||||
"src/main/browser/cookies/client-route/browser-client-route-cookie-import-partition-parity.test.ts": 374,
|
||||
"src/main/browser/cookies/client-route/browser-client-route-cookie-import.test.ts": 101,
|
||||
"src/main/browser/client-hosts/file-transfers/browser-client-upload-buffer-retention.test.ts": 736,
|
||||
"src/main/browser/client-hosts/file-transfers/browser-client-upload-staging.test.ts": 140,
|
||||
@@ -979,7 +978,6 @@
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-grab-capture.test.ts": 516,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-grab-mode.test.ts": 567,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-grab-selection.test.ts": 584,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-guest-lifecycle.test.ts": 554,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-guest-unload.test.ts": 534,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts": 543,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-load-failure-replay.test.ts": 549,
|
||||
@@ -1066,7 +1064,6 @@
|
||||
"src/main/browser/cookies/browser-cookie-import-write.test.ts": 68,
|
||||
"src/main/browser/cookies/browser-cookie-import.comet.test.ts": 1501,
|
||||
"src/main/browser/cookies/browser-cookie-import.test.ts": 1304,
|
||||
"src/main/browser/cookies/browser-cookie-public-suffix-scope.test.ts": 92,
|
||||
"src/main/browser/cookies/browser-cookie-samesite.electron.test.ts": 1882,
|
||||
"src/main/browser/cookies/sources/browser-cookie-decryption.test.ts": 74,
|
||||
"src/main/browser/cookies/sources/browser-cookie-import-undecryptable.test.ts": 541,
|
||||
@@ -1481,7 +1478,6 @@
|
||||
"src/main/codex/configuration/shared-server/codex-shared-server-probe.test.ts": 181,
|
||||
"src/main/codex/configuration/homes/codex-stale-pane-accounts.test.ts": 146,
|
||||
"src/main/codex/history/index/codex-state-db-backfill-recovery-supervision.test.ts": 4660,
|
||||
"src/main/codex/history/index/codex-state-db-backfill-recovery.test.ts": 444,
|
||||
"src/main/codex/history/index/codex-state-db.test.ts": 127,
|
||||
"src/main/codex/structured/acquisition/codex-structured-acquisition-exit-proof.test.ts": 580,
|
||||
"src/main/codex/structured/launch/codex-structured-child-environment.test.ts": 887,
|
||||
@@ -1535,7 +1531,6 @@
|
||||
"src/main/codex/structured/launch/codex-structured-visuals.test.ts": 783,
|
||||
"src/main/codex/structured/background-work/codex-subagent-interrupts.test.ts": 587,
|
||||
"src/main/codex/structured/threads/codex-thread-catch-up-rewind.test.ts": 1479,
|
||||
"src/main/codex/trust/toml/codex-trust-config-concurrent-launch.test.ts": 254,
|
||||
"src/main/codex/trust/toml/codex-trust-config-mutation-queue.test.ts": 51,
|
||||
"src/main/codex/structured/journal/codex-turn-lifecycle-completion.test.ts": 374,
|
||||
"src/main/codex/structured/journal/codex-turn-lifecycle-history.test.ts": 374,
|
||||
@@ -1572,7 +1567,6 @@
|
||||
"src/main/codex/trust/hooks/sta-4737-unreadable-is-not-absent.test.ts": 182,
|
||||
"src/main/codex/configuration/homes/sta-4823-shared-state-survives-a-failed-read.test.ts": 242,
|
||||
"src/main/codex/hooks/installation/windows-hook-command.test.ts": 129,
|
||||
"src/main/codex/hooks/installation/windows-hook-upgrade.test.ts": 312,
|
||||
"src/main/codex/history/bridges/wsl-codex-session-bridge-script.test.ts": 190,
|
||||
"src/main/codex/history/bridges/wsl-codex-session-bridge.test.ts": 346,
|
||||
"src/main/command-code/hook-service.test.ts": 271,
|
||||
@@ -1894,7 +1888,6 @@
|
||||
"src/main/foreign-sqlite-readers/readers/cursor-profile.test.ts": 403,
|
||||
"src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts": 227,
|
||||
"src/main/foreign-sqlite-readers/readers/opencode-prompt-failure.test.ts": 346,
|
||||
"src/main/gemini/hook-service.test.ts": 108,
|
||||
"src/main/ghostty/index.test.ts": 118,
|
||||
"src/main/ghostty/theme-import.test.ts": 100,
|
||||
"src/main/git/base-ref-search-selector.test.ts": 257,
|
||||
@@ -2202,13 +2195,10 @@
|
||||
"src/main/ipc/ai-vault/ai-vault-search-merged-cursor.test.ts": 103,
|
||||
"src/main/ipc/ai-vault/ai-vault-search.test.ts": 1424,
|
||||
"src/main/ipc/ai-vault/ai-vault-unresolved-owner.test.ts": 1436,
|
||||
"src/main/ipc/ai-vault/ai-vault.test.ts": 1453,
|
||||
"src/main/ipc/app.test.ts": 319,
|
||||
"src/main/ipc/automations-external-scope.test.ts": 359,
|
||||
"src/main/ipc/browser/browser-client-page-metadata-ipc.test.ts": 143,
|
||||
"src/main/ipc/browser/browser-preview-tool-authorization.test.ts": 1105,
|
||||
"src/main/ipc/browser/browser-session-profile-ipc.test.ts": 699,
|
||||
"src/main/ipc/browser/browser-tab-registration-wait.test.ts": 234,
|
||||
"src/main/ipc/browser/browser.test.ts": 1209,
|
||||
"src/main/ipc/codex-config-sync.test.ts": 101,
|
||||
"src/main/ipc/command-path/command-path-directory-worker.test.ts": 640,
|
||||
@@ -2217,7 +2207,6 @@
|
||||
"src/main/ipc/created-worktree-root-prune.test.ts": 494,
|
||||
"src/main/ipc/developer-permissions.test.ts": 147,
|
||||
"src/main/ipc/diagnostics.test.ts": 124,
|
||||
"src/main/ipc/doc-preview-grant-ipc.test.ts": 58,
|
||||
"src/main/ipc/dropped-path-resolution.test.ts": 92,
|
||||
"src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts": 166,
|
||||
"src/main/ipc/emulator-renderer-lifetime.test.ts": 116,
|
||||
@@ -4726,7 +4715,6 @@
|
||||
"src/main/window/macos-tahoe-release.test.ts": 48,
|
||||
"src/main/window/main-window-state-lifecycle.test.ts": 60,
|
||||
"src/main/window/main-window-updater-deferred-setup.test.ts": 215,
|
||||
"src/main/window/main-window-webview-security.test.ts": 296,
|
||||
"src/main/window/native-chat-paste-files.test.ts": 666,
|
||||
"src/main/window/native-chat-pasted-text-file.test.ts": 94,
|
||||
"src/main/window/privileged-window-navigation.test.ts": 246,
|
||||
|
||||
@@ -184,7 +184,6 @@ export const WINDOWS_BOUNDARY_TESTS = [
|
||||
'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts',
|
||||
'src/main/claude/claude-status-line-windows.test.ts',
|
||||
'src/main/codex/hooks/installation/windows-hook-command.test.ts',
|
||||
'src/main/codex/hooks/installation/windows-hook-upgrade.test.ts',
|
||||
'src/main/codex/hooks/installation/hook-service-managed-install.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-msys-job.win32.test.ts',
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
resetMobileNativeChatDraftStoreForTests,
|
||||
setMobileNativeChatDraftText
|
||||
} from './native-chat/composer/mobile-native-chat-draft-store'
|
||||
import './native-chat/mobile-native-chat-scope-key'
|
||||
import { resolveActiveSessionTab } from './active-session-tab'
|
||||
|
||||
const initial: Terminal[] = ['a', 'b', 'c'].map((handle) => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type * as WorktreeScanCacheModule from '../../../git/worktrees/catalog/worktree-scan-cache'
|
||||
import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing'
|
||||
import '../../../runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.spec'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { RuntimeBrowserCommands } from '../../../runtime/browser/commands/browser-commands'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../../../runtime/browser/installation/runtime-browser-commands-factory'
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCookieImportDomain } from './browser-cookie-import-policy'
|
||||
|
||||
// Suffix-list changes can widen removal scopes and destroy unrelated sessions.
|
||||
describe('registrable family across public-suffix sections', () => {
|
||||
it('rejects a bare suffix as an import domain but keeps real hosts', () => {
|
||||
expect(normalizeCookieImportDomain('co.uk')).toBeNull()
|
||||
expect(normalizeCookieImportDomain('api.br')).toBeNull()
|
||||
expect(normalizeCookieImportDomain('.example.com')).toBe('example.com')
|
||||
expect(normalizeCookieImportDomain('foo.github.io')).toBe('foo.github.io')
|
||||
})
|
||||
})
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserHostLeaseAuthority } from '../../../../shared/browser/client-hosting/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../../shared/browser/client-hosting/browser-network-execution-host'
|
||||
|
||||
const {
|
||||
bindingStore,
|
||||
detectInstalledBrowsersMock,
|
||||
getProfileMock,
|
||||
getRouteIdentityMock,
|
||||
importCookiesFromBrowserMock,
|
||||
requireRouteBrowserProfileMock,
|
||||
updateProfileSourceMock
|
||||
} = vi.hoisted(() => ({
|
||||
bindingStore: {
|
||||
get: vi.fn(() => null as string | null),
|
||||
set: vi.fn(() => [] as readonly string[]),
|
||||
touch: vi.fn(),
|
||||
findPartitionByFingerprint: vi.fn(() => null as string | null),
|
||||
rebind: vi.fn()
|
||||
},
|
||||
detectInstalledBrowsersMock: vi.fn(),
|
||||
getProfileMock: vi.fn(),
|
||||
getRouteIdentityMock: vi.fn(),
|
||||
importCookiesFromBrowserMock: vi.fn(),
|
||||
requireRouteBrowserProfileMock: vi.fn(),
|
||||
updateProfileSourceMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../browser-cookie-import', () => ({
|
||||
detectInstalledBrowsers: detectInstalledBrowsersMock,
|
||||
importCookiesFromBrowser: importCookiesFromBrowserMock,
|
||||
selectBrowserProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('../../routing/partitions/browser-route-partition-binding-runtime', () => ({
|
||||
currentBrowserRoutePartitionBindingStore: () => bindingStore
|
||||
}))
|
||||
vi.mock('../../sessions/browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
getProfile: getProfileMock,
|
||||
requireRouteBrowserProfile: requireRouteBrowserProfileMock,
|
||||
updateProfileSource: updateProfileSourceMock
|
||||
}
|
||||
}))
|
||||
vi.mock('../../client-hosts/lifecycle/paired-runtime-browser-client-host-runtime', () => ({
|
||||
getPairedRuntimeBrowserClientRouteIdentity: getRouteIdentityMock
|
||||
}))
|
||||
|
||||
import { importCookiesIntoClientRoutePartition } from './browser-client-route-cookie-import'
|
||||
import { BrowserClientNetworkRouteRegistry } from '../../network/client-routes/browser-client-network-route-registry'
|
||||
import {
|
||||
browserAuthorityExecutionHostStorageIdentity,
|
||||
legacyBrowserNativeExecutionHostStorageIdentity
|
||||
} from '../../routing/identity/browser-execution-host-storage-identity'
|
||||
import { createBrowserRoutePartitionBindingStoreFake } from '../../routing/partitions/browser-route-partition-binding-store-fake'
|
||||
import { browserNetworkExecutionHostKey } from '../../network/execution-routes/browser-network-execution-route'
|
||||
import { BrowserRouteSessionRegistry } from '../../routing/sessions/browser-route-session-registry'
|
||||
import type { BrowserRouteElectronSession } from '../../routing/sessions/browser-route-session-policy'
|
||||
|
||||
const orcaProfileId = 'orca-profile-a'
|
||||
const browserProfileId = 'default'
|
||||
const authorityConnectionIdentity = 'paired-runtime:authority-a'
|
||||
const authorityRuntimeId = 'runtime-a'
|
||||
const storageScope = 'e'.repeat(64)
|
||||
|
||||
const authority: BrowserHostLeaseAuthority = {
|
||||
authorityRuntimeId,
|
||||
authorityEpoch: 'epoch-a',
|
||||
browserHostClientId: 'client-a',
|
||||
browserHostGeneration: 1
|
||||
}
|
||||
|
||||
async function pagePartition(host: BrowserNetworkExecutionHost): Promise<string> {
|
||||
const routes = new BrowserClientNetworkRouteRegistry({
|
||||
// Native and WSL routes require their own runtime authority.
|
||||
authority:
|
||||
host.kind === 'ssh' ? authority : { ...authority, authorityRuntimeId: host.runtimeId },
|
||||
authorityStorageKey: storageScope,
|
||||
createRoute: () => ({
|
||||
start: vi.fn(async () => ({ host: '127.0.0.1', port: 43123 })),
|
||||
reconnect: vi.fn(async () => ({ host: '127.0.0.1', port: 43123 })),
|
||||
suspend: vi.fn(),
|
||||
close: vi.fn(async () => {})
|
||||
})
|
||||
})
|
||||
const route = await routes.retain(
|
||||
browserNetworkExecutionHostKey(host),
|
||||
new AbortController().signal
|
||||
)
|
||||
const session: BrowserRouteElectronSession = {
|
||||
setProxy: vi.fn(async () => {}),
|
||||
closeAllConnections: vi.fn(async () => {}),
|
||||
resolveProxy: vi.fn(async () => 'SOCKS5 127.0.0.1:43123')
|
||||
}
|
||||
// Exercise the default partition derivation used by real pages.
|
||||
const sessions = new BrowserRouteSessionRegistry({
|
||||
validateProfile: vi.fn(),
|
||||
getSession: () => session,
|
||||
setupPolicies: vi.fn(),
|
||||
clearPolicies: vi.fn(),
|
||||
retirePageAuthority: vi.fn(() => true),
|
||||
bindingStore: createBrowserRoutePartitionBindingStoreFake()
|
||||
})
|
||||
const handle = await sessions.preparePage({
|
||||
identity: {
|
||||
orcaProfileId,
|
||||
browserProfileId,
|
||||
authorityConnectionIdentity,
|
||||
executionHostIdentity: route.executionHostIdentity
|
||||
},
|
||||
storageScope,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11,
|
||||
proxyEndpoint: route.proxyEndpoint
|
||||
})
|
||||
const partition = handle.partition
|
||||
handle.release()
|
||||
await route.release()
|
||||
await routes.close()
|
||||
return partition
|
||||
}
|
||||
|
||||
async function importPartition(): Promise<string> {
|
||||
bindingStore.get.mockReturnValue(null)
|
||||
getProfileMock.mockReturnValue({ id: browserProfileId, partition: 'persist:legacy-server' })
|
||||
detectInstalledBrowsersMock.mockReturnValue([
|
||||
{ family: 'chrome', label: 'Chrome', profiles: [], selectedProfile: 'Default' }
|
||||
])
|
||||
importCookiesFromBrowserMock.mockResolvedValue({ ok: true, summary: { importedCookies: 1 } })
|
||||
getRouteIdentityMock.mockReturnValue({
|
||||
orcaProfileId,
|
||||
authorityConnectionIdentity,
|
||||
// Settings imports target the server machine, using the host startup identity.
|
||||
executionHostIdentity: browserAuthorityExecutionHostStorageIdentity(storageScope),
|
||||
pairingAuthorityConnectionIdentity: 'paired-runtime:pairing-authority-a',
|
||||
legacyAuthorityConnectionIdentity: 'paired-runtime:legacy-authority-a',
|
||||
legacyExecutionHostIdentity:
|
||||
legacyBrowserNativeExecutionHostStorageIdentity(authorityRuntimeId),
|
||||
storageScope
|
||||
})
|
||||
|
||||
const result = await importCookiesIntoClientRoutePartition({
|
||||
environmentId: 'env-a',
|
||||
browserProfileId,
|
||||
browserFamily: 'chrome'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
return importCookiesFromBrowserMock.mock.calls.at(-1)?.[1] as string
|
||||
}
|
||||
|
||||
describe('cookie-import target vs. client-hosted page partition', () => {
|
||||
it('imports into the same partition a page on the server’s own machine uses', async () => {
|
||||
const imported = await importPartition()
|
||||
|
||||
expect(imported).toBe(
|
||||
await pagePartition({ kind: 'native', runtimeId: authorityRuntimeId, revision: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
// Settings imports exclude nested SSH and WSL workspace partitions.
|
||||
it('does not reach pages hosted on a nested SSH target or WSL distro', async () => {
|
||||
const imported = await importPartition()
|
||||
|
||||
expect(imported).not.toBe(
|
||||
await pagePartition({
|
||||
kind: 'ssh',
|
||||
targetId: 'ssh-1700000000-aaa111',
|
||||
providerEpoch: 'epoch-1',
|
||||
connectionGeneration: 1
|
||||
})
|
||||
)
|
||||
expect(imported).not.toBe(
|
||||
await pagePartition({
|
||||
kind: 'wsl',
|
||||
runtimeId: authorityRuntimeId,
|
||||
revision: 1,
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -102,13 +102,6 @@ describe('importCookiesIntoClientRoutePartition', () => {
|
||||
expect(importCookiesFromBrowserMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a traversing browser profile name before touching the filesystem', async () => {
|
||||
expect(
|
||||
await importCookiesIntoClientRoutePartition({ ...request, browserProfile: '../../etc' })
|
||||
).toMatchObject({ ok: false, reason: 'Invalid browser profile name.' })
|
||||
expect(detectInstalledBrowsersMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A replacement during import must not report success for an abandoned partition.
|
||||
it('fails the import when the server is re-paired mid-import', async () => {
|
||||
getRouteIdentityMock.mockReturnValueOnce(routeIdentity).mockReturnValue(repairedRouteIdentity)
|
||||
@@ -122,25 +115,4 @@ describe('importCookiesIntoClientRoutePartition', () => {
|
||||
expect(importCookiesFromBrowserMock).toHaveBeenCalledOnce()
|
||||
expect(recordClientRouteImportSourceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails the import when the client host is retired mid-import', async () => {
|
||||
getRouteIdentityMock.mockReturnValueOnce(routeIdentity).mockReturnValue(null)
|
||||
|
||||
const result = await importCookiesIntoClientRoutePartition(request)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'The connection to this server ended during the import. Reconnect and try again.'
|
||||
})
|
||||
expect(recordClientRouteImportSourceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown browser session profile', async () => {
|
||||
getProfileMock.mockReturnValue(null)
|
||||
|
||||
expect(await importCookiesIntoClientRoutePartition(request)).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'Session profile not found.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,18 +35,4 @@ describe('decryptCookieValueRaw host-key hash prefix', () => {
|
||||
const encrypted = encryptGcm(hostKeyHash(HOST_KEY))
|
||||
expect(decryptCookieValueRaw(encrypted, gcmKeyResult, HOST_KEY)).toEqual(Buffer.alloc(0))
|
||||
})
|
||||
|
||||
it('keeps a pre-schema-24 value that has no prefix', () => {
|
||||
const encrypted = encryptCbc(Buffer.from('plain-legacy-value'))
|
||||
expect(decryptCookieValueRaw(encrypted, cbcKeyResult, HOST_KEY)).toEqual(
|
||||
Buffer.from('plain-legacy-value')
|
||||
)
|
||||
})
|
||||
|
||||
it('still strips a binary prefix that is not this host key hash', () => {
|
||||
const plaintext = Buffer.concat([hostKeyHash('.other.com'), Buffer.from('value')])
|
||||
expect(decryptCookieValueRaw(encryptCbc(plaintext), cbcKeyResult, HOST_KEY)).toEqual(
|
||||
Buffer.from('value')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,29 +38,6 @@ describe('resolveDocPreviewTargetPath', () => {
|
||||
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'docs/styles.css')).toBeNull()
|
||||
})
|
||||
|
||||
it('authorizes only the requested sibling directory', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo/docs',
|
||||
entryRelativePath: 'docs/report.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'docs/report.html')).toBe(
|
||||
'/srv/repo/docs/report.html'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
|
||||
|
||||
expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true)
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe('/srv/repo/assets/chart.js')
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/theme.css')).toBe(
|
||||
'/srv/repo/assets/theme.css'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Windows semantics when authorizing a sibling directory', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
|
||||
@@ -98,26 +98,6 @@ describe('outside-workspace HTML preview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A macOS host spells /tmp as /private/tmp and returns a case-insensitive volume's stored name.
|
||||
it('reads paths the host spells differently once canonicalized', async () => {
|
||||
const page = grant()
|
||||
authorizeDocPreviewDirectory(page.id, 'assets/styles.css')
|
||||
host(
|
||||
{
|
||||
'/tmp/reports': { canonical: '/private/tmp/reports' },
|
||||
'/tmp/reports/report.html': { canonical: '/private/tmp/reports/report.html' },
|
||||
'/tmp/reports/assets': { canonical: '/private/tmp/reports/assets' },
|
||||
'/tmp/reports/assets/styles.css': { canonical: '/private/tmp/reports/assets/Styles.css' }
|
||||
},
|
||||
{ ok: true, result: { content: 'h1{}', isBinary: false } }
|
||||
)
|
||||
|
||||
await expect(readDocPreviewFile(page, 'report.html')).resolves.toMatchObject({ ok: true })
|
||||
await expect(readDocPreviewFile(page, 'assets/styles.css')).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses symlink redirection outside an approved asset directory', async () => {
|
||||
const page = grant()
|
||||
authorizeDocPreviewDirectory(page.id, 'assets/data.json')
|
||||
|
||||
@@ -67,19 +67,6 @@ function makeRawPayload(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('clampGrabPayload', () => {
|
||||
it('redacts secret-bearing cssClasses outside the attribute path', () => {
|
||||
const payload = clampGrabPayload(
|
||||
makeRawPayload({
|
||||
target: {
|
||||
...makeRawPayload().target,
|
||||
cssClasses: 'primary access_token=secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload?.target.cssClasses).toBe('[redacted]')
|
||||
})
|
||||
|
||||
it('redacts secret-bearing browser annotation metadata fields', () => {
|
||||
const payload = clampGrabPayload(
|
||||
makeRawPayload({
|
||||
@@ -161,60 +148,4 @@ describe('clampGrabPayload', () => {
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
expect(clampGrabPayload(raw, guestUrl)?.page.sanitizedUrl).toBe('')
|
||||
})
|
||||
|
||||
it('keeps safe and ARIA attributes while stripping unsafe names', () => {
|
||||
const payload = clampGrabPayload(
|
||||
makeRawPayload({
|
||||
target: {
|
||||
...makeRawPayload().target,
|
||||
attributes: {
|
||||
ID: 'checkout',
|
||||
class: 'source-code stateful auth token',
|
||||
role: 'button',
|
||||
type: 'button',
|
||||
'aria-label': 'Submit',
|
||||
'aria-labelledby': 'checkout-label',
|
||||
'aria-hidden': 'false',
|
||||
onclick: 'alert(1)',
|
||||
style: 'display:none',
|
||||
'data-secret': 'hidden',
|
||||
'notaria-label': 'unexpected'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload?.target.attributes).toEqual({
|
||||
id: 'checkout',
|
||||
class: 'source-code stateful auth token',
|
||||
role: 'button',
|
||||
type: 'button',
|
||||
'aria-label': 'Submit',
|
||||
'aria-labelledby': 'checkout-label',
|
||||
'aria-hidden': 'false'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'access_token=credential',
|
||||
'API_KEY=credential',
|
||||
'password=credential',
|
||||
'secret=credential',
|
||||
'session_id=credential',
|
||||
'csrf=credential'
|
||||
])('redacts %s in allowed attribute values', (value) => {
|
||||
const payload = clampGrabPayload(
|
||||
makeRawPayload({
|
||||
target: {
|
||||
...makeRawPayload().target,
|
||||
attributes: { title: value, 'aria-label': value }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload?.target.attributes).toEqual({
|
||||
title: '[redacted]',
|
||||
'aria-label': '[redacted]'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
WebContentsView: class {
|
||||
onDestroyed: (() => void) | null = null
|
||||
webContents = {
|
||||
id: 9001,
|
||||
isDestroyed: () => false,
|
||||
close: () => this.onDestroyed?.(),
|
||||
once: (_event: string, listener: () => void) => {
|
||||
this.onDestroyed = listener
|
||||
},
|
||||
focus: vi.fn()
|
||||
}
|
||||
setVisible(): void {}
|
||||
},
|
||||
app: {
|
||||
getPath: browserMocks.appGetPathMock
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromWebContents: browserMocks.browserWindowFromWebContentsMock
|
||||
},
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: {
|
||||
buildFromTemplate: browserMocks.menuBuildFromTemplateMock
|
||||
},
|
||||
screen: {
|
||||
getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock
|
||||
},
|
||||
webContents: {
|
||||
fromId: browserMocks.webContentsFromIdMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../popups/popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from '../../browser-manager'
|
||||
import {
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import { revokeAllDocPreviewGrants } from '../../document-preview/doc-preview-grant-registry'
|
||||
|
||||
const {
|
||||
guestSetBackgroundThrottlingMock,
|
||||
guestSetWindowOpenHandlerMock,
|
||||
guestOpenDevToolsMock,
|
||||
webContentsFromIdMock
|
||||
} = browserMocks
|
||||
|
||||
describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// The headless backend destroys its window when registration returns false.
|
||||
|
||||
it("publishes an offscreen page's top-level address changes, not its subframes'", () => {
|
||||
const stateChanged = vi.fn()
|
||||
browserManager.setBrowserGuestStateChangedListener(stateChanged)
|
||||
let address = 'https://example.com/'
|
||||
const guest = Object.assign(new EventEmitter(), {
|
||||
id: 515,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'window'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
getURL: vi.fn(() => address)
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.registerOffscreenGuest({
|
||||
browserPageId: 'offscreen-page',
|
||||
worktreeId: 'remote-worktree',
|
||||
webContentsId: guest.id
|
||||
})
|
||||
const navigateInPage = (url: string): void => {
|
||||
address = url
|
||||
guest.emit('did-navigate-in-page', {}, url, true, 1, 1)
|
||||
}
|
||||
|
||||
guest.emit('did-navigate-in-page', {}, 'https://frame.example/#x', false, 1, 2)
|
||||
expect(stateChanged).not.toHaveBeenCalled()
|
||||
|
||||
navigateInPage('https://example.com/#top')
|
||||
expect(stateChanged).toHaveBeenCalledTimes(1)
|
||||
expect(stateChanged).toHaveBeenLastCalledWith('remote-worktree')
|
||||
|
||||
// replaceState without a new address.
|
||||
navigateInPage('https://example.com/#top')
|
||||
expect(stateChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
address = 'https://example.com/next'
|
||||
guest.emit('did-navigate', {}, address, 200, 'OK')
|
||||
expect(stateChanged).toHaveBeenCalledTimes(2)
|
||||
|
||||
// A full navigation also counts as the address to compare with.
|
||||
navigateInPage('https://example.com/next')
|
||||
expect(stateChanged).toHaveBeenCalledTimes(2)
|
||||
|
||||
browserManager.unregisterGuest('offscreen-page')
|
||||
expect(guest.listenerCount('did-navigate-in-page')).toBe(0)
|
||||
})
|
||||
|
||||
it('retires stale guest mappings when a page re-registers after a process swap', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const oldGuestOnMock = vi.fn()
|
||||
const oldGuestOffMock = vi.fn()
|
||||
const newGuestOnMock = vi.fn()
|
||||
const newGuestOffMock = vi.fn()
|
||||
const oldGuest = {
|
||||
id: 501,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
send: vi.fn(),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: oldGuestOnMock,
|
||||
off: oldGuestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://old.example')
|
||||
}
|
||||
const newGuest = {
|
||||
id: 502,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
send: vi.fn(),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: newGuestOnMock,
|
||||
off: newGuestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://new.example')
|
||||
}
|
||||
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === oldGuest.id) {
|
||||
return oldGuest
|
||||
}
|
||||
if (id === newGuest.id) {
|
||||
return newGuest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(oldGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: oldGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(newGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: newGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
// Registration also reports audio state; this test is about load-failure routing.
|
||||
rendererSendMock.mockClear()
|
||||
|
||||
const oldDidFailLoadHandler = oldGuestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedUrl: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
const newDidFailLoadHandler = newGuestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedUrl: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
|
||||
oldDidFailLoadHandler?.(null, -105, 'Old guest failed', 'https://old.example', true)
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
|
||||
newDidFailLoadHandler?.(null, -106, 'New guest failed', 'https://new.example', true)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:guest-load-failed', {
|
||||
browserPageId: 'browser-1',
|
||||
loadError: {
|
||||
code: -106,
|
||||
description: 'New guest failed',
|
||||
validatedUrl: 'https://new.example'
|
||||
}
|
||||
})
|
||||
expect(oldGuestOffMock).toHaveBeenCalled()
|
||||
expect(browserManager.getGuestWebContentsId('browser-1')).toBe(newGuest.id)
|
||||
expect(browserManager.getTabIdForWebContentsId(oldGuest.id)).toBeNull()
|
||||
expect(browserManager.getTabIdForWebContentsId(newGuest.id)).toBe('browser-1')
|
||||
|
||||
browserManager.unregisterGuest('browser-1')
|
||||
expect(browserManager.getTabIdForWebContentsId(newGuest.id)).toBeNull()
|
||||
})
|
||||
|
||||
// Browsing guests track override invalidation without attaching a debugger.
|
||||
|
||||
// The renderer re-applies a tab's mute on each registration report and clears the
|
||||
// speaker on the unregister report, so neither may be skipped.
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { link, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as ownerIdentity from '../../../agent-hooks/managed-hook-owner-identity'
|
||||
import {
|
||||
_internals,
|
||||
resolveCodexBackfillSupervisorLockRoot,
|
||||
runCodexStateDbBackfillRecovery,
|
||||
startCodexStateDbBackfillRecoveryInBackground,
|
||||
withCodexBackfillSupervisorLock
|
||||
} from './codex-state-db-backfill-recovery'
|
||||
import type { CodexStateDbBackfillStatus } from './codex-state-db'
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function createFakeChild(): EventEmitter & {
|
||||
stdin: { end: ReturnType<typeof vi.fn> }
|
||||
exitCode: number | null
|
||||
signalCode: NodeJS.Signals | null
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
stdin: { end: vi.fn() },
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: vi.fn(() => true)
|
||||
})
|
||||
}
|
||||
|
||||
async function createTemporaryRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-codex-backfill-recovery-'))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
_internals.resetForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('Codex state DB backfill recovery', () => {
|
||||
it('does not start a supervisor for an unreadable index', async () => {
|
||||
const home = await createTemporaryRoot()
|
||||
await writeFile(join(home, 'state_5.sqlite'), 'not a sqlite database')
|
||||
const run = vi.fn()
|
||||
const withLock = vi.fn()
|
||||
|
||||
await expect(
|
||||
startCodexStateDbBackfillRecoveryInBackground(home, {
|
||||
run,
|
||||
withLock: withLock as never
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(withLock).not.toHaveBeenCalled()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds permanent claimant exits just beyond the fast-failure window', async () => {
|
||||
const controller = new AbortController()
|
||||
const children = new Map<ReturnType<typeof createFakeChild>, number>()
|
||||
const timerDurations: number[] = []
|
||||
let now = 0
|
||||
let exitCount = 0
|
||||
let pendingTimers = 0
|
||||
let maxPendingTimers = 0
|
||||
let maxLiveChildren = 0
|
||||
let safetyFuseTripped = false
|
||||
const terminate = vi.fn(async () => {})
|
||||
const spawnProcess = vi.fn(() => {
|
||||
const child = createFakeChild()
|
||||
children.set(child, now + 10_001)
|
||||
maxLiveChildren = Math.max(maxLiveChildren, children.size)
|
||||
return child
|
||||
})
|
||||
const sleep = vi.fn(async (ms: number) => {
|
||||
timerDurations.push(ms)
|
||||
pendingTimers += 1
|
||||
maxPendingTimers = Math.max(maxPendingTimers, pendingTimers)
|
||||
const wakeAt = now + ms
|
||||
for (const [child, exitAt] of children) {
|
||||
if (exitAt <= wakeAt) {
|
||||
now = exitAt
|
||||
children.delete(child)
|
||||
exitCount += 1
|
||||
child.emit('exit', 1, null)
|
||||
}
|
||||
}
|
||||
now = wakeAt
|
||||
pendingTimers -= 1
|
||||
if (ms === 2_000 && spawnProcess.mock.calls.length === 6) {
|
||||
safetyFuseTripped = true
|
||||
controller.abort()
|
||||
throw Object.assign(new Error('reproduction safety fuse'), { name: 'AbortError' })
|
||||
}
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const summary = await runCodexStateDbBackfillRecovery('/managed-home', controller.signal, {
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus => ({
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
})),
|
||||
terminate,
|
||||
sleep,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect({
|
||||
summary,
|
||||
spawnCount: spawnProcess.mock.calls.length,
|
||||
exitCount,
|
||||
timerCount: timerDurations.length,
|
||||
pollCount: timerDurations.filter((ms) => ms === 5_000).length,
|
||||
backoffCount: timerDurations.filter((ms) => ms === 2_000).length,
|
||||
liveChildren: children.size,
|
||||
pendingTimers,
|
||||
maxLiveChildren,
|
||||
maxPendingTimers,
|
||||
terminateCount: terminate.mock.calls.length,
|
||||
safetyFuseTripped
|
||||
}).toEqual({
|
||||
summary: { outcome: 'gave-up', spawnCount: 5 },
|
||||
spawnCount: 5,
|
||||
exitCount: 5,
|
||||
timerCount: 19,
|
||||
pollCount: 15,
|
||||
backoffCount: 4,
|
||||
liveChildren: 0,
|
||||
pendingTimers: 0,
|
||||
maxLiveChildren: 1,
|
||||
maxPendingTimers: 1,
|
||||
terminateCount: 0,
|
||||
safetyFuseTripped: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('Codex backfill supervisor owner lock', () => {
|
||||
it('recovers a dead owner whose PID was reused with a different start identity', async () => {
|
||||
const userData = await createTemporaryRoot()
|
||||
vi.stubEnv('ORCA_USER_DATA_PATH', userData)
|
||||
const home = join(userData, 'managed-home')
|
||||
const lockRoot = resolveCodexBackfillSupervisorLockRoot(home)
|
||||
const lockParent = join(lockRoot, '.orca')
|
||||
const token = '00000000-0000-4000-8000-000000000000'
|
||||
const ownerPath = join(lockParent, `managed-hook-install.owner-${token}.json`)
|
||||
const lockPath = join(lockParent, 'managed-hook-install.lock')
|
||||
await mkdir(lockParent, { recursive: true })
|
||||
await writeFile(
|
||||
ownerPath,
|
||||
JSON.stringify({
|
||||
token,
|
||||
pid: process.pid,
|
||||
hostIdentity: await ownerIdentity.readManagedHookHostIdentity(),
|
||||
processIdentity: 'stale-process-start-time'
|
||||
})
|
||||
)
|
||||
await link(ownerPath, lockPath)
|
||||
|
||||
await expect(
|
||||
withCodexBackfillSupervisorLock(home, undefined, async () => 'recovered')
|
||||
).resolves.toBe('recovered')
|
||||
})
|
||||
|
||||
it('does not interfere with a live supervisor from another Orca instance', async () => {
|
||||
const userData = await createTemporaryRoot()
|
||||
vi.stubEnv('ORCA_USER_DATA_PATH', userData)
|
||||
const home = join(userData, 'managed-home')
|
||||
let releaseFirst!: () => void
|
||||
const first = withCodexBackfillSupervisorLock(
|
||||
home,
|
||||
undefined,
|
||||
async () => await new Promise<void>((resolve) => (releaseFirst = resolve))
|
||||
)
|
||||
await vi.waitFor(async () => {
|
||||
await expect(
|
||||
import('node:fs/promises').then(({ readFile }) =>
|
||||
readFile(
|
||||
join(
|
||||
resolveCodexBackfillSupervisorLockRoot(home),
|
||||
'.orca',
|
||||
'managed-hook-install.lock'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
).resolves.toContain('processIdentity')
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const secondRun = vi.fn(async () => {})
|
||||
setTimeout(() => controller.abort(), 40)
|
||||
|
||||
await expect(
|
||||
withCodexBackfillSupervisorLock(home, controller.signal, secondRun)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(secondRun).not.toHaveBeenCalled()
|
||||
releaseFirst()
|
||||
await first
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import {
|
||||
codexRolloutHardlinkIdentity,
|
||||
dedupeCodexRolloutFileAliases
|
||||
} from '../../../ai-vault/scanners/codex/codex-session-root-dedup'
|
||||
import '../../../ai-vault/scanners/codex/codex-session-root-dedup'
|
||||
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from '../../../codex-accounts/host-codex-managed-home-ownership'
|
||||
import '../../../codex-accounts/host-codex-managed-home-ownership'
|
||||
|
||||
const lstatFaults = vi.hoisted(() => ({
|
||||
path: null as string | null,
|
||||
@@ -89,21 +77,6 @@ describe('prepareLegacySharedCodexSessionResume', () => {
|
||||
expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino)
|
||||
})
|
||||
|
||||
it('coalesces concurrent resumes of the same legacy rollout', async () => {
|
||||
const [first, second] = await Promise.all([prepare(), prepare()])
|
||||
|
||||
expect(first.useRealCodexHome).toBe(true)
|
||||
expect(second.useRealCodexHome).toBe(true)
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toContain('session_meta')
|
||||
})
|
||||
|
||||
it('fails clearly and retryably instead of approving a missing rollout', async () => {
|
||||
rmSync(rolloutPath)
|
||||
|
||||
await expect(prepare()).rejects.toThrow(/Retry resume/)
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when a different rollout already owns the target path', async () => {
|
||||
mkdirSync(join(targetRolloutPath(), '..'), { recursive: true })
|
||||
writeFileSync(targetRolloutPath(), '{"different":true}\n', 'utf-8')
|
||||
@@ -137,125 +110,3 @@ describe('prepareLegacySharedCodexSessionResume', () => {
|
||||
return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath))
|
||||
}
|
||||
})
|
||||
|
||||
// Vault dedup's smallest bridge alias may name a peer account instead of the selected one.
|
||||
describe('per-account resume repin', () => {
|
||||
let root: string
|
||||
// Make the selected account lose the path tie-break.
|
||||
let peerHome: string
|
||||
let selectedHome: string
|
||||
let recordedRolloutPath: string
|
||||
let bridgedRolloutPath: string
|
||||
|
||||
const rolloutRelativePath = join(
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
'rollout-2026-07-20T10-00-00-abcdef00-1234-4321-9999-cafecafecafe.jsonl'
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-codex-account-repin-'))
|
||||
peerHome = join(root, 'codex-accounts', '11111111-aaaa-4aaa-8aaa-111111111111', 'home')
|
||||
selectedHome = join(root, 'codex-accounts', '99999999-bbbb-4bbb-8bbb-999999999999', 'home')
|
||||
recordedRolloutPath = join(selectedHome, rolloutRelativePath)
|
||||
bridgedRolloutPath = join(peerHome, rolloutRelativePath)
|
||||
mkdirSync(dirname(recordedRolloutPath), { recursive: true })
|
||||
writeFileSync(recordedRolloutPath, '{"type":"session_meta"}\n', 'utf-8')
|
||||
mkdirSync(dirname(bridgedRolloutPath), { recursive: true })
|
||||
linkSync(recordedRolloutPath, bridgedRolloutPath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([['recorded rollout first'], ['bridged alias first']])(
|
||||
'repins the surviving vault row to the selected account, discovered %s',
|
||||
async (order) => {
|
||||
const recorded = rolloutCandidate(recordedRolloutPath, selectedHome)
|
||||
const bridged = rolloutCandidate(bridgedRolloutPath, peerHome)
|
||||
const survivors = dedupeCodexRolloutFileAliases(
|
||||
order === 'recorded rollout first' ? [recorded, bridged] : [bridged, recorded],
|
||||
rolloutCandidateAccessors
|
||||
)
|
||||
|
||||
expect(survivors).toEqual([bridged])
|
||||
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: bridged.filePath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
repinOptions()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false, substituteCodexHome: selectedHome })
|
||||
}
|
||||
)
|
||||
|
||||
it('declines while the bridge has not yet linked the rollout into the selected home', async () => {
|
||||
const unbridgedSelectedHome = join(
|
||||
root,
|
||||
'codex-accounts',
|
||||
'ffffffff-cccc-4ccc-8ccc-ffffffffffff',
|
||||
'home'
|
||||
)
|
||||
mkdirSync(unbridgedSelectedHome, { recursive: true })
|
||||
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: bridgedRolloutPath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{ ...repinOptions(), getSelectedHostAccountCodexHomePath: () => unbridgedSelectedHome }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('refuses when the selected rollout is temporarily unreadable', async () => {
|
||||
lstatFaults.path = recordedRolloutPath
|
||||
|
||||
await expect(
|
||||
prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: bridgedRolloutPath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
repinOptions()
|
||||
)
|
||||
).rejects.toBeInstanceOf(ManagedCodexHomeTemporarilyUnavailableError)
|
||||
})
|
||||
|
||||
function repinOptions() {
|
||||
return {
|
||||
isHostSystemDefaultRealHomeSelected: () => false,
|
||||
getSelectedHostAccountCodexHomePath: () => selectedHome
|
||||
}
|
||||
}
|
||||
|
||||
function rolloutCandidate(filePath: string, codexHome: string) {
|
||||
const stat = statSync(filePath)
|
||||
return {
|
||||
filePath,
|
||||
codexHome,
|
||||
file: { dev: stat.dev, ino: stat.ino, nlink: stat.nlink }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const rolloutCandidateAccessors = {
|
||||
isCodex: () => true,
|
||||
getFilePath: (candidate: { filePath: string }) => candidate.filePath,
|
||||
getCodexHome: (candidate: { codexHome: string }) => candidate.codexHome,
|
||||
getHardlinkIdentity: (candidate: { file: { dev: number; ino: number; nlink: number } }) =>
|
||||
codexRolloutHardlinkIdentity(candidate.file)
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as Os from 'node:os'
|
||||
import { setupCodexHookHomes } from './hook-service-test-harness'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
|
||||
vi.mock('os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Os>()),
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
import './hook-service'
|
||||
import { CodexHookService } from './codex-hook-service-implementation'
|
||||
import { CODEX_EVENTS, CODEX_EVENT_LABEL } from '../definitions/codex-hook-definition'
|
||||
import { buildCodexHookCommand as getManagedCommand } from '../definitions/codex-hook-command-form'
|
||||
import '../../../agent-hooks/installer-utils'
|
||||
import { readHooksJson } from '../../../agent-hooks/hooks-json-read'
|
||||
import { wrapWindowsHookCommand } from '../../../agent-hooks/windows-hook-command'
|
||||
import { upsertHookTrustEntries } from '../../trust/toml/config-toml-trust'
|
||||
import {
|
||||
computeCodexTrustedHash as computeTrustedHash,
|
||||
getExplicitHomeCodexHookSourcePath as getCodexExplicitHomeHookSourcePath
|
||||
} from '../../trust/identity/codex-trust-identity'
|
||||
|
||||
const homes = setupCodexHookHomes(homedirMock, getPathMock)
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('Unicode Windows hook upgrade', () => {
|
||||
it('replaces all encoded commands and trust hashes while preserving user hooks on reinstall', async () => {
|
||||
const home = join(homes.tmpHome, '测试 用户')
|
||||
mkdirSync(home)
|
||||
homedirMock.mockReturnValue(home)
|
||||
const runtimeHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const configPath = join(runtimeHome, 'hooks.json')
|
||||
const tomlPath = join(runtimeHome, 'config.toml')
|
||||
const scriptPath = join(home, '.orca', 'agent-hooks', 'codex-hook.cmd')
|
||||
const oldCommand = wrapWindowsHookCommand(scriptPath)
|
||||
const userHome = join(home, '.codex')
|
||||
mkdirSync(userHome)
|
||||
const userConfig = JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] }
|
||||
})
|
||||
writeFileSync(join(userHome, 'hooks.json'), userConfig)
|
||||
mkdirSync(runtimeHome, { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
hooks: Object.fromEntries(
|
||||
CODEX_EVENTS.map((event) => [
|
||||
event,
|
||||
[{ hooks: [{ type: 'command', command: oldCommand, timeout: 10 }] }]
|
||||
])
|
||||
)
|
||||
})
|
||||
)
|
||||
upsertHookTrustEntries(
|
||||
tomlPath,
|
||||
CODEX_EVENTS.map((event) => ({
|
||||
sourcePath: getCodexExplicitHomeHookSourcePath(configPath),
|
||||
eventLabel: CODEX_EVENT_LABEL[event],
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: oldCommand,
|
||||
timeoutSec: 10
|
||||
}))
|
||||
)
|
||||
const service = new CodexHookService()
|
||||
expect(service.getStatus(runtimeHome).state).not.toBe('installed')
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(service.getStatus(runtimeHome).state).toBe('installed')
|
||||
const hooks = readHooksJson(configPath)?.hooks
|
||||
const trust = readFileSync(tomlPath, 'utf8')
|
||||
for (const event of CODEX_EVENTS) {
|
||||
const commands = hooks?.[event]?.flatMap((group) => group.hooks ?? []) ?? []
|
||||
expect(
|
||||
commands.filter((hook) => hook.command === getManagedCommand(scriptPath))
|
||||
).toHaveLength(1)
|
||||
expect(commands.some((hook) => hook.command === oldCommand)).toBe(false)
|
||||
const entry = {
|
||||
sourcePath: getCodexExplicitHomeHookSourcePath(configPath),
|
||||
eventLabel: CODEX_EVENT_LABEL[event],
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: getManagedCommand(scriptPath),
|
||||
timeoutSec: 10
|
||||
}
|
||||
expect(trust).toContain(computeTrustedHash(entry))
|
||||
expect(trust).not.toContain(computeTrustedHash({ ...entry, command: oldCommand }))
|
||||
}
|
||||
expect(
|
||||
hooks?.Stop?.some((group) => group.hooks?.some((hook) => hook.command === 'user-hook'))
|
||||
).toBe(true)
|
||||
expect(readFileSync(join(userHome, 'hooks.json'), 'utf8')).toBe(userConfig)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,286 +0,0 @@
|
||||
import { 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 {
|
||||
CodexHookTrustGrantRequest,
|
||||
CodexHookTrustGrantSessionResult
|
||||
} from '../../app-server/codex-app-server-client'
|
||||
import type { CodexManagedTrustGrantPlan } from '../grants/codex-managed-trust-grant-plan'
|
||||
import type { CodexTrustEntry } from './config-toml-trust'
|
||||
|
||||
// Keep the guest identity probe unstamped without requiring wsl.exe.
|
||||
vi.mock('@orca/process-host', () => ({
|
||||
runProcess: vi.fn(async () => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
}))
|
||||
}))
|
||||
|
||||
const testState = {
|
||||
fakeHomeDir: '',
|
||||
userDataDir: '',
|
||||
previousUserDataPath: undefined as string | undefined
|
||||
}
|
||||
|
||||
vi.mock(import('node:os'), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return { ...actual, homedir: () => testState.fakeHomeDir }
|
||||
})
|
||||
|
||||
const { CodexAppServerUnsupportedError } = await import('../../app-server/codex-app-server-session')
|
||||
const { codexAppServerCapabilityCache } =
|
||||
await import('../../app-server/codex-app-server-capability-cache')
|
||||
const { _internals, grantManagedCodexHookTrust } = await import('../grants/codex-hook-trust-grant')
|
||||
const { markCodexProjectTrusted } = await import('../../../agent-launch/trust/agent-trust-presets')
|
||||
const { getLocalCodexTrustConfigFiles } = await import('../../configuration/homes/codex-home-paths')
|
||||
const { setCodexTrustGrantTelemetry } = await import('../grants/codex-trust-grant-telemetry')
|
||||
const [
|
||||
{ readHookTrustEntries, upsertHookTrustEntries },
|
||||
{
|
||||
computeCodexTrustKey: computeTrustKey,
|
||||
computeCodexTrustedHash: computeTrustedHash,
|
||||
normalizeCodexHookTrustLookupKey: normalizeHookTrustKeyForLookup
|
||||
}
|
||||
] = await Promise.all([import('./config-toml-trust'), import('../identity/codex-trust-identity')])
|
||||
|
||||
let runtimeHomeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-home-'))
|
||||
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-userdata-'))
|
||||
testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = testState.userDataDir
|
||||
runtimeHomeDir = join(testState.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
writeFileSync(join(runtimeHomeDir, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true })
|
||||
codexAppServerCapabilityCache.clear()
|
||||
_internals.resetDiagnostics()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_internals.setGrantSessionRunner(null)
|
||||
setCodexTrustGrantTelemetry(() => {})
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (testState.previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath
|
||||
}
|
||||
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
|
||||
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(testState.userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'"
|
||||
|
||||
function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntry {
|
||||
return {
|
||||
sourcePath: join(runtimeHomeDir, 'hooks.json'),
|
||||
eventLabel,
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: MANAGED_COMMAND,
|
||||
timeoutSec: 10
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlan(
|
||||
entries: CodexTrustEntry[],
|
||||
overrides: Partial<CodexManagedTrustGrantPlan> = {}
|
||||
): CodexManagedTrustGrantPlan {
|
||||
return {
|
||||
runtimeHomePath: runtimeHomeDir,
|
||||
tomlPath: join(runtimeHomeDir, 'config.toml'),
|
||||
managedCommand: MANAGED_COMMAND,
|
||||
managedEntries: entries,
|
||||
host: {
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
linuxRuntimeHome: overrides.runtimeHomePath ?? runtimeHomeDir
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/** Write across an await to model concurrent app-server mutations. */
|
||||
function writingSessionRunner(args: {
|
||||
tomlPath: string
|
||||
entries: CodexTrustEntry[]
|
||||
hashPrefix: string
|
||||
gate?: Promise<void>
|
||||
outcome?: 'granted' | 'verify-failed'
|
||||
}) {
|
||||
return async (
|
||||
_request: CodexHookTrustGrantRequest
|
||||
): Promise<CodexHookTrustGrantSessionResult> => {
|
||||
const granted = args.entries.map((entry) => {
|
||||
const key = computeTrustKey(entry)
|
||||
return {
|
||||
key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(key),
|
||||
trustedHash: `${args.hashPrefix}${entry.eventLabel}`
|
||||
}
|
||||
})
|
||||
await tick()
|
||||
upsertHookTrustEntries(
|
||||
args.tomlPath,
|
||||
args.entries.map((entry, index) => ({ ...entry, trustedHash: granted[index].trustedHash }))
|
||||
)
|
||||
if (args.gate) {
|
||||
await args.gate
|
||||
}
|
||||
if (args.outcome === 'verify-failed') {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: 'listed hash mismatch',
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
}
|
||||
return { outcome: 'granted', wroteTrust: true, entries: granted }
|
||||
}
|
||||
}
|
||||
|
||||
describe('two Codex pane launches against one config.toml', () => {
|
||||
it("leaves a concurrent grant's records in place when a sibling grant fails", async () => {
|
||||
// Warm the capability cache so both grant sessions can overlap.
|
||||
codexAppServerCapabilityCache.rememberSupported('wsl:Ubuntu')
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const entries = [managedEntry('session_start')]
|
||||
let call = 0
|
||||
let releaseFirst!: () => void
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
_internals.setGrantSessionRunner(async (request) => {
|
||||
call += 1
|
||||
const isFirst = call === 1
|
||||
return writingSessionRunner({
|
||||
tomlPath,
|
||||
entries,
|
||||
hashPrefix: isFirst ? 'sha256:doomed-' : 'sha256:survivor-',
|
||||
gate: isFirst ? firstGate : undefined,
|
||||
outcome: isFirst ? 'verify-failed' : 'granted'
|
||||
})(request)
|
||||
})
|
||||
|
||||
const doomed = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const survivor = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
await tick()
|
||||
await tick()
|
||||
releaseFirst()
|
||||
|
||||
expect(await doomed).toMatchObject({ lane: 'fallback', reason: 'verify-failed' })
|
||||
expect(await survivor).toMatchObject({ lane: 'rpc' })
|
||||
const trust = readHookTrustEntries(tomlPath)
|
||||
const key = normalizeHookTrustKeyForLookup(computeTrustKey(entries[0]))
|
||||
expect(trust.get(key)?.trustedHash).toBe('sha256:survivor-session_start')
|
||||
})
|
||||
|
||||
it('writes project trust while a grant session is still running, and keeps it', async () => {
|
||||
codexAppServerCapabilityCache.rememberSupported('wsl:Ubuntu')
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const entries = [managedEntry('session_start')]
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-concurrent-ws-'))
|
||||
let releaseSession!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => {
|
||||
releaseSession = resolve
|
||||
})
|
||||
|
||||
_internals.setGrantSessionRunner(
|
||||
writingSessionRunner({
|
||||
tomlPath,
|
||||
entries,
|
||||
hashPrefix: 'sha256:doomed-',
|
||||
gate: sessionGate,
|
||||
outcome: 'verify-failed'
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const grant = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
await tick()
|
||||
await tick()
|
||||
await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir))
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
|
||||
releaseSession()
|
||||
expect(await grant).toMatchObject({ lane: 'fallback', reason: 'verify-failed' })
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent capability probes against a cold host', () => {
|
||||
it('leaves the waiter config.toml untouched when the shared probe reports unsupported', async () => {
|
||||
const secondHome = join(testState.userDataDir, 'second-runtime-home')
|
||||
mkdirSync(secondHome, { recursive: true })
|
||||
writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
const waiterToml = join(secondHome, 'config.toml')
|
||||
const waiterEntry = {
|
||||
...managedEntry('session_start'),
|
||||
sourcePath: join(secondHome, 'hooks.json')
|
||||
}
|
||||
upsertHookTrustEntries(waiterToml, [
|
||||
{ ...waiterEntry, trustedHash: computeTrustedHash(waiterEntry) }
|
||||
])
|
||||
const before = readFileSync(waiterToml, 'utf-8')
|
||||
|
||||
let releaseProbe!: () => void
|
||||
const probeGate = new Promise<void>((resolve) => {
|
||||
releaseProbe = resolve
|
||||
})
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
await probeGate
|
||||
throw new CodexAppServerUnsupportedError('hooks/grantTrust: method not found')
|
||||
})
|
||||
|
||||
const first = grantManagedCodexHookTrust(buildPlan([managedEntry('session_start')]))
|
||||
const waiter = grantManagedCodexHookTrust(
|
||||
buildPlan([waiterEntry], { runtimeHomePath: secondHome, tomlPath: waiterToml })
|
||||
)
|
||||
await tick()
|
||||
releaseProbe()
|
||||
await first
|
||||
expect(await waiter).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' })
|
||||
expect(readFileSync(waiterToml, 'utf-8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reentrancy under concurrency', () => {
|
||||
it('completes a grant nested inside an installer that already holds both lanes', async () => {
|
||||
const { runExclusivelyForCodexTrustConfig } =
|
||||
await import('./codex-trust-config-mutation-queue')
|
||||
const entries = [managedEntry('session_start')]
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const systemToml = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
_internals.setGrantSessionRunner(
|
||||
writingSessionRunner({ tomlPath, entries, hashPrefix: 'sha256:nested-' })
|
||||
)
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-nested-ws-'))
|
||||
try {
|
||||
const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () =>
|
||||
runExclusivelyForCodexTrustConfig(systemToml, async () => {
|
||||
await markCodexProjectTrusted(
|
||||
workspace,
|
||||
getLocalCodexTrustConfigFiles(testState.fakeHomeDir)
|
||||
)
|
||||
return grantManagedCodexHookTrust(buildPlan(entries))
|
||||
})
|
||||
)
|
||||
expect(outcome).toMatchObject({ lane: 'rpc' })
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
@@ -49,13 +49,12 @@ vi.mock('../../../agent-hooks/installer-utils-remote', async (importOriginal) =>
|
||||
import {
|
||||
isCodexConfigTomlRefusedError,
|
||||
moveHookTrustEntries,
|
||||
readHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import { computeCodexTrustKey as computeTrustKey } from '../identity/codex-trust-identity'
|
||||
import { escapeTomlBasicString as escapeTomlString } from '../../configuration/toml/config-toml-syntax'
|
||||
import { installCodexHooksRemote } from '../../hooks/installation/codex-hook-remote-install'
|
||||
import '../../hooks/installation/codex-hook-remote-install'
|
||||
|
||||
let dir: string
|
||||
let tomlPath: string
|
||||
@@ -128,43 +127,4 @@ describe('hook approval writes never break a config.toml Codex can load', () =>
|
||||
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('the SSH installer reports the refusal and leaves the remote config.toml untouched', async () => {
|
||||
const remoteToml = '/home/u/.codex/config.toml'
|
||||
const original = 'model = "m"\nhooks = { state = {} }\n'
|
||||
mocks.remoteFiles.set(remoteToml, original)
|
||||
mocks.remoteFiles.set('/home/u/.codex/hooks.json', '{"hooks":{}}')
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked remote helpers never touch the SFTP handle.
|
||||
const status = await installCodexHooksRemote({} as SFTPWrapper, '/home/u')
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: 'error',
|
||||
detail: expect.stringContaining('defines hook approvals in a form Orca cannot add to')
|
||||
})
|
||||
expect(mocks.remoteFiles.get(remoteToml)).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading approvals Codex or the user wrote without a table header', () => {
|
||||
it('reads an approval written as dotted keys', () => {
|
||||
writeFileSync(tomlPath, `hooks.state."${tomlKey(stopEntry())}".trusted_hash = "sha256:user"\n`)
|
||||
|
||||
expect(readHookTrustEntries(tomlPath).get(computeTrustKey(stopEntry()))).toEqual({
|
||||
trustedHash: 'sha256:user',
|
||||
enabled: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('reads an approval written as an inline table', () => {
|
||||
writeFileSync(
|
||||
tomlPath,
|
||||
`[hooks]\nstate = { "${tomlKey(stopEntry())}" = { trusted_hash = "sha256:user", enabled = false } }\n`
|
||||
)
|
||||
|
||||
expect(readHookTrustEntries(tomlPath).get(computeTrustKey(stopEntry()))).toEqual({
|
||||
trustedHash: 'sha256:user',
|
||||
enabled: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { GeminiHookService } from './hook-service'
|
||||
import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command'
|
||||
|
||||
describe('GeminiHookService', () => {
|
||||
let homeDir: string
|
||||
let userDataDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-gemini-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-gemini-userdata-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
getPathMock.mockImplementation((name: string) => {
|
||||
if (name === 'userData') {
|
||||
return userDataDir
|
||||
}
|
||||
throw new Error(`unexpected getPath(${name})`)
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => {
|
||||
const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
|
||||
const staleManagedHookPath =
|
||||
process.platform === 'win32'
|
||||
? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}`
|
||||
: `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}`
|
||||
const staleManagedCommand =
|
||||
process.platform === 'win32'
|
||||
? staleManagedHookPath
|
||||
: `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi`
|
||||
const configDir = join(homeDir, '.gemini')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(configDir, 'settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: staleManagedCommand }]
|
||||
},
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo user-authored' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const status = new GeminiHookService().install()
|
||||
const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8'))
|
||||
const preToolCommands = config.hooks.PreToolUse.flatMap(
|
||||
(definition: { hooks?: { command: string }[] }) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(preToolCommands).toEqual(['echo user-authored'])
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? wrapWindowsBatchHookCommand(join(homeDir, '.orca', 'agent-hooks', managedHookFileName))
|
||||
: new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultSession
|
||||
} from '../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import type * as CachedSessionListModule from '../../ai-vault/cached-session-list'
|
||||
import type * as SessionParseCacheModule from '../../ai-vault/parse-cache/session-parse-cache-store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanLocalSessions: vi.fn(),
|
||||
resolveAiVaultSessionTitlesInService: vi.fn(),
|
||||
listSubagentsInService: vi.fn(),
|
||||
scanRuntimeAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
ipcHandle: vi.fn(),
|
||||
trashItem: vi.fn(),
|
||||
deleteAiVaultSessionFile: vi.fn(),
|
||||
invalidateAiVaultSessionListCache: vi.fn(),
|
||||
invalidateSessionParseCacheEntry: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { on: vi.fn() },
|
||||
ipcMain: { handle: mocks.ipcHandle },
|
||||
shell: { trashItem: mocks.trashItem }
|
||||
}))
|
||||
|
||||
vi.mock('../../ai-vault/scan-service/session-scanner-service-spawn', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
scanAiVaultSessionsInService: mocks.scanLocalSessions,
|
||||
resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService,
|
||||
listAiVaultSubagentSessionsInService: mocks.listSubagentsInService
|
||||
}))
|
||||
|
||||
vi.mock('../../ai-vault/session-delete', () => ({
|
||||
deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile
|
||||
}))
|
||||
|
||||
// Mock only invalidation so routing and cache tests retain the real list implementation.
|
||||
vi.mock('../../ai-vault/cached-session-list', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CachedSessionListModule>()
|
||||
return {
|
||||
...actual,
|
||||
invalidateAiVaultSessionListCache: mocks.invalidateAiVaultSessionListCache
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../ai-vault/parse-cache/session-parse-cache-store', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionParseCacheModule>()
|
||||
return {
|
||||
...actual,
|
||||
invalidateSessionParseCacheEntry: mocks.invalidateSessionParseCacheEntry
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../wsl/discovery/distro-catalog', () => ({
|
||||
listRunningWslDistrosAsync: vi.fn().mockResolvedValue([]),
|
||||
listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]),
|
||||
hasCachedWslDistros: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const { resolveOmpSessionsDir } = await import('../../ai-vault/scanners/omp/omp-session-root')
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete')
|
||||
await import('../../ai-vault/host-session-delete')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanLocalSessions.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] })
|
||||
mocks.listSubagentsInService.mockResolvedValue({ sessions: [], issues: [] })
|
||||
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(
|
||||
result([session('runtime:remote-server', 'runtime-session')])
|
||||
)
|
||||
})
|
||||
|
||||
describe('prepareSessionResume IPC', () => {
|
||||
it('preserves SSH session homes without reading their paths locally', async () => {
|
||||
const prepareSessionResume = vi.fn()
|
||||
const prepareRuntimeSessionResume = vi.fn()
|
||||
registerAiVaultHandlers({ prepareSessionResume, prepareRuntimeSessionResume })
|
||||
|
||||
await expect(
|
||||
getPrepareSessionResumeHandler()(
|
||||
{},
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
|
||||
codexHome: '/managed',
|
||||
executionHostId: 'ssh:dev-box'
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ useRealCodexHome: false })
|
||||
expect(prepareSessionResume).not.toHaveBeenCalled()
|
||||
expect(prepareRuntimeSessionResume).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function getPrepareSessionResumeHandler(): (
|
||||
event: unknown,
|
||||
args: unknown
|
||||
) => Promise<{ useRealCodexHome: boolean }> {
|
||||
const registration = mocks.ipcHandle.mock.calls.find(
|
||||
([channel]) => channel === 'aiVault:prepareSessionResume'
|
||||
)
|
||||
if (!registration) {
|
||||
throw new Error('aiVault:prepareSessionResume was not registered')
|
||||
}
|
||||
return registration[1]
|
||||
}
|
||||
|
||||
describe('listAiVaultSubagentSessions gating', () => {
|
||||
const claudeRoot = join(homedir(), '.claude', 'projects')
|
||||
|
||||
it('rejects a dot-segment traversal out of the Claude projects root', async () => {
|
||||
// Built with sep (not join) so the `..` segments survive into the arg.
|
||||
const traversal = [claudeRoot, '..', '..', '..', 'etc', 'passwd.jsonl'].join(sep)
|
||||
|
||||
const result = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath: traversal,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listSubagentsInService).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an OMP path that only sits inside another agent root', async () => {
|
||||
// Each agent's allowlist is its own root: a Claude path must not be
|
||||
// readable through the OMP branch (or vice versa).
|
||||
const crossAgent = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'omp',
|
||||
parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'),
|
||||
executionHostId: 'local'
|
||||
})
|
||||
const traversal = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'omp',
|
||||
// Built with sep (not join) so the `..` segments survive into the arg.
|
||||
parentFilePath: [resolveOmpSessionsDir(), '..', '..', '..', 'etc', 'passwd.jsonl'].join(sep),
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(crossAgent).toEqual({ sessions: [], issues: [] })
|
||||
expect(traversal).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listSubagentsInService).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAiVaultSession', () => {
|
||||
const args = {
|
||||
agent: 'gemini' as const,
|
||||
sessionId: 'session-1',
|
||||
filePath: '/home/ada/.gemini/tmp/sess.json',
|
||||
executionHostId: 'local' as const
|
||||
}
|
||||
|
||||
it("routes a server-hosted session to its owning host and drops that host's cached list", async () => {
|
||||
const deleteRuntimeAiVaultSession = vi.fn().mockResolvedValue({ outcome: 'deleted' })
|
||||
const invalidateMultiHostListCache = vi.fn()
|
||||
|
||||
const result = await deleteAiVaultSessionWithDeps(
|
||||
{ ...args, executionHostId: 'runtime:gpu%20box' },
|
||||
{ invalidateMultiHostListCache, deleteRuntimeSession: deleteRuntimeAiVaultSession }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ outcome: 'deleted' })
|
||||
expect(deleteRuntimeAiVaultSession).toHaveBeenCalledWith(
|
||||
'gpu box',
|
||||
expect.objectContaining({ agent: 'gemini', filePath: args.filePath })
|
||||
)
|
||||
expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1)
|
||||
// This computer never touches a path that belongs to another host.
|
||||
expect(mocks.deleteAiVaultSessionFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never routes the unresolved-owner sentinel to a host', async () => {
|
||||
const deleteRuntimeAiVaultSession = vi.fn()
|
||||
mocks.deleteAiVaultSessionFile.mockResolvedValue({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'non-local-host'
|
||||
})
|
||||
|
||||
await deleteAiVaultSessionWithDeps(
|
||||
{ ...args, executionHostId: 'runtime:unresolved-owner' },
|
||||
{
|
||||
invalidateMultiHostListCache: vi.fn(),
|
||||
deleteRuntimeSession: deleteRuntimeAiVaultSession
|
||||
}
|
||||
)
|
||||
|
||||
expect(deleteRuntimeAiVaultSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/tmp/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt:
|
||||
sessionId === 'runtime-session'
|
||||
? '2026-07-04T03:00:00.000Z'
|
||||
: sessionId === 'remote-session'
|
||||
? '2026-07-04T02:00:00.000Z'
|
||||
: '2026-07-04T01:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as DocPreviewGuestPolicyModule from '../../browser/document-preview/doc-preview-guest-policy'
|
||||
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
getAuthorizedGuestMock,
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
cancelGrabOpMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock,
|
||||
openDevToolsMock,
|
||||
setViewportOverrideMock,
|
||||
previewAuthoritySpy,
|
||||
registrationWaitSpy
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getAuthorizedGuestMock: vi.fn(),
|
||||
setGrabModeMock: vi.fn().mockResolvedValue(true),
|
||||
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
|
||||
cancelGrabOpMock: vi.fn(),
|
||||
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
|
||||
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
|
||||
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
|
||||
openDevToolsMock: vi.fn().mockResolvedValue(true),
|
||||
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
|
||||
previewAuthoritySpy: vi.fn(),
|
||||
registrationWaitSpy: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-passwords-ipc', () => ({ registerBrowserPasswordsHandlers: vi.fn() }))
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
|
||||
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
|
||||
}))
|
||||
|
||||
vi.mock('../../browser/browser-manager', () => ({
|
||||
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
|
||||
browserManager: {
|
||||
registerGuest: vi.fn(() => true),
|
||||
attachGuestPolicies: vi.fn(),
|
||||
unregisterGuest: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn(),
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map()),
|
||||
getWorktreeIdForTab: vi.fn(),
|
||||
getAuthorizedGuest: getAuthorizedGuestMock,
|
||||
setGrabMode: setGrabModeMock,
|
||||
awaitGrabSelection: awaitGrabSelectionMock,
|
||||
cancelGrabOp: cancelGrabOpMock,
|
||||
captureSelectionScreenshot: captureSelectionScreenshotMock,
|
||||
extractHoverPayload: extractHoverPayloadMock,
|
||||
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
|
||||
openDevTools: openDevToolsMock,
|
||||
setViewportOverride: setViewportOverrideMock,
|
||||
cancelDownload: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Spy on the real policy so registry selection remains observable.
|
||||
vi.mock('../../browser/document-preview/doc-preview-guest-policy', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
|
||||
return {
|
||||
...actual,
|
||||
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
|
||||
previewAuthoritySpy(browserPageId, senderWebContentsId)
|
||||
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Keep registration waits real; observe only which targets enter them.
|
||||
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
|
||||
return {
|
||||
...actual,
|
||||
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
|
||||
registrationWaitSpy(args[0])
|
||||
return actual.waitForNextTabRegistration(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust'
|
||||
import { browserManager } from '../../browser/browser-manager'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy
|
||||
} from '../../browser/document-preview/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../../browser/document-preview/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../../shared/doc-preview-scheme'
|
||||
|
||||
const HOST_RENDERER_ID = 91
|
||||
const OTHER_RENDERER_ID = 92
|
||||
// Match browser-grab-ipc's registration timeout.
|
||||
const GRAB_REGISTRATION_WAIT_MS = 1_000
|
||||
|
||||
/** Exhaustively classify invoke channels as guest tools or browser/session/profile management. */
|
||||
const TOOL_CHANNELS = [
|
||||
'browser:setGrabMode',
|
||||
'browser:awaitGrabSelection',
|
||||
'browser:cancelGrab',
|
||||
'browser:captureSelectionScreenshot',
|
||||
'browser:extractHoverPayload',
|
||||
'browser:setAnnotationViewportBridge'
|
||||
]
|
||||
|
||||
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
|
||||
|
||||
function registeredHandlers(): Map<string, Handler> {
|
||||
const handlers = new Map<string, Handler>()
|
||||
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
function trustedSender(id: number): { sender: Electron.WebContents } {
|
||||
return {
|
||||
sender: {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
}
|
||||
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function grantForNewDocPage(): { id: string; browserPageId: string } {
|
||||
nextDocPageOrdinal += 1
|
||||
const browserPageId = `doc-page-${nextDocPageOrdinal}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
return { id: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
type PreviewGuestContents = {
|
||||
isDestroyed: () => boolean
|
||||
getURL: () => string
|
||||
}
|
||||
|
||||
function renderPreviewForGrant(
|
||||
grant: { id: string; browserPageId: string },
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): {
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
contents: PreviewGuestContents
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const browserPageId = grant.browserPageId
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
// Policy attaches mid-load, when the guest already reports its document URL.
|
||||
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
|
||||
let contentsDestroyed = false
|
||||
const guest = {
|
||||
isFocused: () => true,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => documentUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
|
||||
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
|
||||
return {
|
||||
grantId: grant.id,
|
||||
browserPageId,
|
||||
contents: guest,
|
||||
// Contents can be destroyed before Chromium delivers the destroyed event.
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function liveRenderedPreview(
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): ReturnType<typeof renderPreviewForGrant> {
|
||||
return renderPreviewForGrant(grantForNewDocPage(), hostId)
|
||||
}
|
||||
|
||||
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
|
||||
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
|
||||
switch (channel) {
|
||||
case 'browser:setGrabMode':
|
||||
return { browserPageId, enabled: true }
|
||||
case 'browser:awaitGrabSelection':
|
||||
return { browserPageId, opId: 'op-1' }
|
||||
case 'browser:captureSelectionScreenshot':
|
||||
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
|
||||
case 'browser:setAnnotationViewportBridge':
|
||||
return {
|
||||
browserPageId,
|
||||
enabled: true,
|
||||
emitViewport: true,
|
||||
markers: [],
|
||||
token: 'annotation-bridge-token-1'
|
||||
}
|
||||
default:
|
||||
return { browserPageId }
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap viewport resolvers as well as direct guest arguments.
|
||||
function resolvesToGuest(argument: unknown, guest: PreviewGuestContents): boolean {
|
||||
return argument === guest || (typeof argument === 'function' && argument() === guest)
|
||||
}
|
||||
|
||||
const GUEST_RECEIVING_MOCKS = [
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', '')
|
||||
setTrustedBrowserRendererWebContentsId(HOST_RENDERER_ID)
|
||||
// Revoke before clearing mocks so disposal calls belong to the previous test.
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
// This suite checks channel authorization; real manager registry splitting is covered separately.
|
||||
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
|
||||
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
)
|
||||
setGrabModeMock.mockResolvedValue(true)
|
||||
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
|
||||
captureSelectionScreenshotMock.mockResolvedValue(null)
|
||||
extractHoverPayloadMock.mockResolvedValue(null)
|
||||
setAnnotationViewportBridgeMock.mockResolvedValue(true)
|
||||
registerBrowserHandlers({ getSettings: vi.fn() })
|
||||
// Fake timers settle registration waits for guests that never attach.
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
|
||||
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
|
||||
return pending
|
||||
}
|
||||
|
||||
describe('doc preview tool authorization', () => {
|
||||
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
|
||||
// Assert the actual WebContents receiving the tool operation.
|
||||
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
|
||||
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
|
||||
)
|
||||
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
|
||||
})
|
||||
|
||||
// Browser/session management must never consult the document registry.
|
||||
|
||||
it.each(TOOL_CHANNELS)(
|
||||
'refuses %s from a renderer that does not host the preview',
|
||||
async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// A live grant and trusted sender are insufficient once contents are destroyed.
|
||||
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
preview.markContentsDestroyed()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
// Document tools wait for their own attaching guest instead of returning not-ready immediately.
|
||||
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
|
||||
const grant = grantForNewDocPage()
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: grant.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
|
||||
|
||||
const preview = renderPreviewForGrant(grant)
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
|
||||
})
|
||||
|
||||
// Unrendered documents must eventually answer not-ready.
|
||||
|
||||
// Revocation, including re-minting, is how previews withdraw and release retained tool state.
|
||||
|
||||
// unregisterGuest must not drop preview intent and silently settle its grab without arming.
|
||||
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handlers = registeredHandlers()
|
||||
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// Intent exists before the queued operation runs; test disposal in that gap.
|
||||
const unregistered = handlers.get('browser:unregisterGuest')?.(
|
||||
trustedSender(HOST_RENDERER_ID),
|
||||
{ browserPageId: preview.browserPageId }
|
||||
)
|
||||
|
||||
// A dropped intent also returns ok; only the driven guest proves the grab armed.
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
|
||||
expect(unregistered).toBe(false)
|
||||
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The same guard must still allow browser tabs to dispose their grabs.
|
||||
|
||||
// An open document must not answer for a browsing page ID.
|
||||
it('never hands a browsing page id the guest of a document that is open', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get('browser:extractHoverPayload')
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
|
||||
|
||||
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
|
||||
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
|
||||
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
|
||||
getGuestWebContentsId: vi.fn(() => null as number | null),
|
||||
webContentsFromId: vi.fn(() => null as unknown)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
|
||||
vi.mock('../../browser/browser-manager', () => ({
|
||||
browserManager: {
|
||||
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
|
||||
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
|
||||
getGuestWebContentsId: mocks.getGuestWebContentsId
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
waitForAnyTabRegistration,
|
||||
waitForNextTabRegistration,
|
||||
waitForWorktreeTabRegistration
|
||||
} from './browser-tab-registration-wait'
|
||||
import { installDocPreviewGuestPolicy } from '../../browser/document-preview/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../../browser/document-preview/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../../shared/doc-preview-scheme'
|
||||
|
||||
const WAIT_MS = 1_000
|
||||
|
||||
function attachDocumentGuest(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
|
||||
}
|
||||
|
||||
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
|
||||
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
|
||||
pending.then(
|
||||
() => {
|
||||
state = 'resolved'
|
||||
},
|
||||
() => {
|
||||
state = 'rejected'
|
||||
}
|
||||
)
|
||||
return () => state
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('a workspace document registering', () => {
|
||||
it('settles a wait that already names its page', async () => {
|
||||
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-1')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(settled()).toBe('resolved')
|
||||
})
|
||||
|
||||
// CLI/agent any-tab waits need drivable browser tabs; documents satisfy only their own page wait.
|
||||
it('leaves the waits that ask for any browser tab still waiting', async () => {
|
||||
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
|
||||
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-2')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(worktreeWait()).toBe('pending')
|
||||
expect(anyWait()).toBe('pending')
|
||||
|
||||
// Verify unsatisfied waits actually time out.
|
||||
await vi.advanceTimersByTimeAsync(WAIT_MS)
|
||||
expect(worktreeWait()).toBe('rejected')
|
||||
expect(anyWait()).toBe('rejected')
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
isTrustedBrowserRenderer: vi.fn(),
|
||||
reportDocPreviewLinkClick: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.handlers.set(channel, handler)
|
||||
},
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.listeners.set(channel, listener)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser/browser-renderer-trust', () => ({
|
||||
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
|
||||
}))
|
||||
vi.mock('../browser/document-preview/doc-preview-guest-policy', () => ({
|
||||
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
|
||||
}))
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
|
||||
}))
|
||||
|
||||
import {
|
||||
registerDocPreviewGrantHandlers,
|
||||
type DocPreviewGrantRequest
|
||||
} from './doc-preview-grant-ipc'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/document-preview/doc-preview-grant-registry'
|
||||
import {
|
||||
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
const REQUEST: DocPreviewGrantRequest = {
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
requestBase: '/home/alice/docs',
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'doc-page-1'
|
||||
}
|
||||
|
||||
const sender = { id: 7 }
|
||||
|
||||
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('mint handler not registered')
|
||||
}
|
||||
return handler({ sender }, request) as { grantId: string; url: string }
|
||||
}
|
||||
|
||||
function revoke(grantId: string): boolean {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('revoke handler not registered')
|
||||
}
|
||||
return handler({ sender }, grantId) as boolean
|
||||
}
|
||||
|
||||
function authorize(grantId: unknown, relativePath: unknown): boolean {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('authorize handler not registered')
|
||||
}
|
||||
return handler({ sender }, grantId, relativePath) as boolean
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.handlers.clear()
|
||||
mocks.listeners.clear()
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
registerDocPreviewGrantHandlers()
|
||||
})
|
||||
|
||||
describe('document preview grant handlers', () => {
|
||||
// Keep browser/document registries disjoint when minting the document identity.
|
||||
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
|
||||
mocks.getGuestWebContentsId.mockReturnValue(42)
|
||||
|
||||
expect(() => mint()).toThrow(/browsing page/)
|
||||
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
|
||||
})
|
||||
|
||||
// Untrusted senders must receive no filesystem-read grant.
|
||||
it('mints nothing for a sender that is not the trusted renderer', () => {
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(() => mint()).toThrow(/Untrusted/)
|
||||
})
|
||||
|
||||
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
|
||||
const result = mint()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(revoke(result.grantId)).toBe(false)
|
||||
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('lets only the trusted renderer authorize a valid requested directory', () => {
|
||||
const result = mint({
|
||||
...REQUEST,
|
||||
requestBase: '/home/alice',
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'docs/index.html'
|
||||
})
|
||||
|
||||
expect(authorize(result.grantId, 'assets/app.js')).toBe(true)
|
||||
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
|
||||
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
expect(authorize(result.grantId, 'secrets/token.txt')).toBe(false)
|
||||
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
|
||||
})
|
||||
|
||||
it('refuses malformed directory authorization arguments', () => {
|
||||
const result = mint()
|
||||
|
||||
expect(authorize(result.grantId, '../secret.txt')).toBe(false)
|
||||
expect(authorize(result.grantId, null)).toBe(false)
|
||||
expect(authorize(null, 'assets/app.js')).toBe(false)
|
||||
expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false)
|
||||
})
|
||||
})
|
||||
-453
@@ -1,453 +0,0 @@
|
||||
import { getEventListeners } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createAcpAgentConnection } from '../../../../acp/acp-agent-connection'
|
||||
import { openAcpAdapterRig } from '../../../../acp/acp-structured-adapter.test-support'
|
||||
import { readAntigravitySignInLine } from '../../../../antigravity/antigravity-sign-in-url'
|
||||
import { findInErrorChain } from '../../../../provider-process/provider-error-chain'
|
||||
import { createFakePipedChild } from '../../../../../shared/__fixtures__/fake-spawned-child'
|
||||
import { closeProviderTimelineRigs } from '../../../agent-session-timeline/provider-timeline-assembler-test-support'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../../shared/agent-session/lifecycle/agent-session-mutation-envelope'
|
||||
import { AgentSessionStartFailure } from '../../startup/structured-agent-session-start-failure'
|
||||
import { CodexAppServerExitError } from '../../../../codex/app-server/codex-app-server-exit-error'
|
||||
import { CodexAppServerRequestError } from '../../../../codex/app-server/codex-app-server-request-error'
|
||||
import {
|
||||
adapterFor,
|
||||
fakeCodex,
|
||||
THREAD_ID
|
||||
} from '../../../../codex/structured/session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
import { openTestAgentSessionRecordStore } from '../../../../runtime/agent-session/records/agent-session-record-store-test-harness'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import {
|
||||
claudeAndCodexAgents,
|
||||
claudeAndCodexRouter
|
||||
} from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
import type * as ExitObservation from '../../termination/structured-agent-session-exit-observation'
|
||||
import { StructuredAgentSessionHost } from '../structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestAttachParams,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from '../structured-agent-session-logger'
|
||||
import { restTestSend } from './structured-agent-session-rest-test-rig'
|
||||
import type * as StartFailure from '../../startup/structured-agent-session-start-failure'
|
||||
|
||||
// Every adapter's startup receipts and every acquisition's root-exit receipt, to count what they
|
||||
// still hold after each start, whichever adapter or sink created them.
|
||||
const created = vi.hoisted(() => ({
|
||||
endings: new Array<StartFailure.AgentSessionStartEndings>(),
|
||||
rootExits: new Array<ExitObservation.StructuredAgentSessionRootExitReceipt>()
|
||||
}))
|
||||
const retainedReceipts = () =>
|
||||
created.endings.reduce((sum, endings) => sum + endings['endings'].size, 0)
|
||||
vi.mock('../../startup/structured-agent-session-start-failure', async (original) => {
|
||||
const actual = await original<typeof StartFailure>()
|
||||
class CountedEndings extends actual.AgentSessionStartEndings {
|
||||
constructor() {
|
||||
super()
|
||||
created.endings.push(this)
|
||||
}
|
||||
}
|
||||
return { ...actual, AgentSessionStartEndings: CountedEndings }
|
||||
})
|
||||
vi.mock('../../termination/structured-agent-session-exit-observation', async (original) => {
|
||||
const actual = await original<typeof ExitObservation>()
|
||||
class CountedRootExit extends actual.StructuredAgentSessionRootExitReceipt {
|
||||
constructor() {
|
||||
super()
|
||||
created.rootExits.push(this)
|
||||
}
|
||||
}
|
||||
return { ...actual, StructuredAgentSessionRootExitReceipt: CountedRootExit }
|
||||
})
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const CEILING_MS = 20_000
|
||||
type Cycle = 'ready' | 'final' | 'retried' | 'expired' | 'stopped'
|
||||
const CYCLES: readonly Cycle[] = ['ready', 'final', 'retried', 'expired', 'stopped']
|
||||
const SCRIPTS: Record<Cycle, ('ok' | 'refuse' | 'hang')[]> = {
|
||||
ready: ['ok'],
|
||||
final: ['refuse'],
|
||||
retried: ['hang', 'ok'],
|
||||
expired: ['hang'],
|
||||
stopped: ['hang']
|
||||
}
|
||||
|
||||
let root: string
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']
|
||||
})
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-start-lifecycle-'))
|
||||
resetHostTestOperationIds()
|
||||
created.endings.length = 0
|
||||
created.rootExits.length = 0
|
||||
})
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await closeTestJournalHostDatabases()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
/** Runs `count` rounds of each start outcome through the production host, router and Codex adapter. */
|
||||
async function runStarts(count: number) {
|
||||
// Each thread/start or thread/resume takes the next scripted answer.
|
||||
const answers: ('ok' | 'refuse' | 'hang')[] = []
|
||||
const threadAnswer = () => {
|
||||
const answer = answers.shift()
|
||||
if (answer === 'refuse') {
|
||||
throw new CodexAppServerRequestError('thread/start', -32600, 'invalid', 'invalid')
|
||||
}
|
||||
return answer === 'hang'
|
||||
? new Promise(() => {})
|
||||
: { thread: { id: THREAD_ID }, model: 'gpt-live', reasoningEffort: 'medium' }
|
||||
}
|
||||
const codex = fakeCodex({ 'thread/start': threadAnswer, 'thread/resume': threadAnswer })
|
||||
let host: StructuredAgentSessionHost | undefined
|
||||
const store = await openTestAgentSessionRecordStore(join(root, `starts-${count}`))
|
||||
const adapter = adapterFor(codex, {}, [], {
|
||||
// Once a thread exists, each new child resumes it.
|
||||
resolveLaunch: async () => ({
|
||||
command: 'codex',
|
||||
args: ['app-server'],
|
||||
cwd: '/work/repo',
|
||||
codexHome: null,
|
||||
resumeThreadId: store.getRecord(SESSION)?.providerHandleChain.length ? THREAD_ID : null
|
||||
}),
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'ended' && 'cause' in event) {
|
||||
void host?.handleAdapterEvent(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
const other = adapterFor(fakeCodex())
|
||||
const router = claudeAndCodexRouter({ claude: other, codex: adapter }, async () => {
|
||||
await adapter.closeAll()
|
||||
await other.closeAll()
|
||||
})
|
||||
const dispatch = vi.fn(async () => ({
|
||||
state: 'accepted' as const,
|
||||
providerIdentity: {
|
||||
provider: 'codex' as const,
|
||||
threadId: THREAD_ID,
|
||||
turnId: `turn-${dispatch.mock.calls.length}`,
|
||||
ordinal: dispatch.mock.calls.length
|
||||
}
|
||||
}))
|
||||
// Turn work is not under test; every start-path call reaches the production router.
|
||||
Object.assign(router, { dispatch })
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexAgents({ claude: other, codex: adapter }),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: router,
|
||||
journalDatabase: openTestJournalHostDatabase(join(root, `starts-${count}`)),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => `spawn-${codex.connections.length}`,
|
||||
startupLimits: { ceilingMs: CEILING_MS },
|
||||
idleSweep: { intervalMs: 60 * 60_000, idleMs: () => 60 * 60_000 },
|
||||
now: () => NOW
|
||||
})
|
||||
const { runtimeState } = host.collaboratorsForTests()
|
||||
const spies = {
|
||||
opens: vi.spyOn(router, 'open'),
|
||||
// The router reaches every registered adapter by exact generation.
|
||||
codexEndingReads: vi.spyOn(adapter, 'startupEnding'),
|
||||
otherEndingReads: vi.spyOn(other, 'startupEnding'),
|
||||
codexRetirements: vi.spyOn(adapter, 'retireStartup'),
|
||||
otherRetirements: vi.spyOn(other, 'retireStartup'),
|
||||
timersScheduled: vi.spyOn(globalThis, 'setTimeout')
|
||||
}
|
||||
// Abort handles the host still holds, and the listeners on their signals.
|
||||
const abortSignals = () =>
|
||||
[...runtimeState.acquireAborts['inFlight'].values()].flatMap((waits) =>
|
||||
[...waits].map(({ controller }) => controller.signal)
|
||||
)
|
||||
const abortListeners = () =>
|
||||
abortSignals().reduce((sum, signal) => sum + getEventListeners(signal, 'abort').length, 0)
|
||||
const held = () => ({
|
||||
timers: vi.getTimerCount(),
|
||||
abortListeners: abortListeners(),
|
||||
abortHandles: abortSignals().length,
|
||||
trackedStarts: runtimeState.startupAttempts['open'].size,
|
||||
owners: runtimeState.startupAttempts['owners'].size,
|
||||
flights: runtimeState.startFlights['flying'].size,
|
||||
bufferedExits: runtimeState.startFlights['exits'].size,
|
||||
receipts: retainedReceipts(),
|
||||
rootExitListeners: created.rootExits.reduce((sum, exit) => sum + exit['listeners'].size, 0),
|
||||
liveChildren: codex.connections.filter((connection) => !connection.closed).length,
|
||||
// Host work still tracked or queued after its caller returned.
|
||||
trackedTasks: host!['tasks']['attaching'].size,
|
||||
queuedTasks: host!['tasks']['chains'].size
|
||||
})
|
||||
const eventually = (assertion: () => unknown) =>
|
||||
vi.waitFor(assertion, { timeout: 10_000, interval: 0 })
|
||||
|
||||
const submission = async (id: string) =>
|
||||
(await host!.journalSnapshot(SESSION)).submissions.find((row) => row.clientMessageId === id)
|
||||
const failureRows = async () =>
|
||||
(await host!.journalSnapshot(SESSION)).items.filter(
|
||||
(item) => item.body.kind === 'status' && item.body.tone === 'error'
|
||||
).length
|
||||
const phase = () => host!.collaboratorsForTests().sessions.get(SESSION)?.child?.phase
|
||||
const exitChild = (connection = codex.connections.at(-1)!) => {
|
||||
connection.closed = true
|
||||
const exit = new CodexAppServerExitError('codex app-server connection ended')
|
||||
// A duplicate terminal report must change nothing.
|
||||
connection.handlers.onExit?.(exit, { expected: false })
|
||||
connection.handlers.onExit?.(exit, { expected: false })
|
||||
}
|
||||
|
||||
// Work the starts did, counted from a ready chat.
|
||||
const work = async (): Promise<Record<string, number>> => ({
|
||||
...Object.fromEntries(
|
||||
Object.entries(spies).map(([field, spy]) => [field, spy.mock.calls.length])
|
||||
),
|
||||
spawns: codex.connections.length,
|
||||
failureRows: await failureRows()
|
||||
})
|
||||
|
||||
answers.push('ok')
|
||||
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
|
||||
await eventually(() => expect(phase()).toBe('ready'))
|
||||
const begun = await work()
|
||||
const after: Record<Cycle, ReturnType<typeof held>[]> = {
|
||||
ready: [],
|
||||
final: [],
|
||||
retried: [],
|
||||
expired: [],
|
||||
stopped: []
|
||||
}
|
||||
let rows = 0
|
||||
// What a Stopped start holds while its child's cleanup is still running.
|
||||
const stopping: ReturnType<typeof held>[] = []
|
||||
// What one start holds while its child is still answering.
|
||||
const starting: ReturnType<typeof held>[] = []
|
||||
for (let round = 0; round < count; round++) {
|
||||
for (const cycle of CYCLES) {
|
||||
// The chat's ready child dies, so the next message starts a new one as the script says.
|
||||
if (phase() === 'ready') {
|
||||
exitChild()
|
||||
await eventually(() => expect(phase()).toBeUndefined())
|
||||
}
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
rows = await failureRows()
|
||||
const spawned = codex.connections.length
|
||||
answers.push(...SCRIPTS[cycle])
|
||||
const send = restTestSend(cycle, store.getRecord(SESSION)!.lease.runtimeFence)
|
||||
expect(await host.send(CALLER, send), `${cycle} ${round}`).toMatchObject({ ok: true })
|
||||
if (cycle === 'retried') {
|
||||
await eventually(() => expect(answers).toEqual(['ok']))
|
||||
starting.push(held())
|
||||
exitChild()
|
||||
}
|
||||
if (cycle === 'expired') {
|
||||
await eventually(() => expect(answers).toEqual([]))
|
||||
await vi.advanceTimersByTimeAsync(CEILING_MS)
|
||||
}
|
||||
if (cycle === 'stopped') {
|
||||
await eventually(() => expect(answers).toEqual([]))
|
||||
// The user stops the start while its open waits; the child's cleanup is held, then ends.
|
||||
const connection = codex.connections.at(-1)!
|
||||
const close = connection.close
|
||||
const cleanup = Promise.withResolvers<void>()
|
||||
connection.close = async () => {
|
||||
await cleanup.promise
|
||||
return close()
|
||||
}
|
||||
const fence = store.getRecord(SESSION)!.lease.runtimeFence
|
||||
const stop = {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: fence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.cancel',
|
||||
sessionId: SESSION,
|
||||
fields: {}
|
||||
})
|
||||
}
|
||||
const stopped = host.cancel(CALLER, { envelope: stop })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
stopping.push(held())
|
||||
cleanup.resolve()
|
||||
expect(await stopped).toMatchObject({ ok: true })
|
||||
}
|
||||
// The start's owner decides it: the message runs on a ready child, or one row says why not.
|
||||
const started = cycle === 'ready' || cycle === 'retried'
|
||||
await eventually(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(runtimeState.startFlights.has(SESSION)).toBe(false)
|
||||
expect(phase(), `${cycle} ${round}`).toBe(started ? 'ready' : undefined)
|
||||
expect((await submission(send.envelope.clientOperationId))?.dispatchState).toBe(
|
||||
started ? 'accepted' : 'rejected'
|
||||
)
|
||||
expect(await failureRows()).toBe(rows + (cycle === 'final' || cycle === 'expired' ? 1 : 0))
|
||||
})
|
||||
expect(codex.connections).toHaveLength(spawned + (cycle === 'retried' ? 2 : 1))
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
after[cycle].push(held())
|
||||
}
|
||||
}
|
||||
const done = await work()
|
||||
await host.close(SESSION, 'evict')
|
||||
await host.flushAllStreamedEvents()
|
||||
await router.closeAll()
|
||||
vi.restoreAllMocks()
|
||||
const counts = Object.fromEntries(
|
||||
Object.entries(done).map(([field, count]) => [field, count - begun[field]])
|
||||
)
|
||||
return { after, starting, stopping, counts }
|
||||
}
|
||||
|
||||
it('returns every start resource to baseline and does constant work per start, at N and 10N', async () => {
|
||||
const n = await runStarts(1)
|
||||
const tenN = await runStarts(10)
|
||||
for (const cycle of CYCLES) {
|
||||
const [first] = n.after[cycle]
|
||||
// Nothing a finished start owned survives it: no owner, abort handle, tracked attempt, flight,
|
||||
// buffered exit, adapter receipt, root-exit listener or tracked task, and no exited child left
|
||||
// running.
|
||||
expect(first, cycle).toMatchObject({
|
||||
abortHandles: 0,
|
||||
trackedStarts: 0,
|
||||
owners: 0,
|
||||
flights: 0,
|
||||
bufferedExits: 0,
|
||||
receipts: 0,
|
||||
rootExitListeners: 0,
|
||||
liveChildren: cycle === 'ready' || cycle === 'retried' ? 1 : 0,
|
||||
trackedTasks: 0,
|
||||
queuedTasks: 0
|
||||
})
|
||||
// Timers and abort listeners held after each start do not grow with the starts before it.
|
||||
for (const held of tenN.after[cycle]) {
|
||||
expect(held, cycle).toEqual(first)
|
||||
}
|
||||
}
|
||||
// While a start waits on its child it holds a fixed set, however many starts came before it:
|
||||
// one deadline timer, the flight's and the owner's abort handles, one tracked attempt, owner,
|
||||
// receipt and root-exit subscription.
|
||||
expect(n.starting[0]).toMatchObject({
|
||||
timers: n.after.ready[0].timers + 1,
|
||||
abortHandles: 2,
|
||||
trackedStarts: 1,
|
||||
owners: 1,
|
||||
flights: 1,
|
||||
receipts: 1,
|
||||
rootExitListeners: 1
|
||||
})
|
||||
for (const held of tenN.starting) {
|
||||
expect(held).toEqual(n.starting[0])
|
||||
}
|
||||
// A Stop releases the start's owner at once; while its child's cleanup is held, that cleanup is
|
||||
// the one task still tracked and queued, however many Stops came before it.
|
||||
expect(n.stopping[0]).toMatchObject({
|
||||
owners: 0,
|
||||
flights: 0,
|
||||
abortHandles: 0,
|
||||
receipts: 0,
|
||||
trackedTasks: 1,
|
||||
queuedTasks: 1,
|
||||
liveChildren: 1
|
||||
})
|
||||
for (const held of tenN.stopping) {
|
||||
expect(held).toEqual(n.stopping[0])
|
||||
}
|
||||
// Per round of five starts: six spawns and opens (the retried start opens twice) and a failure
|
||||
// row for the final and the expired start; a Stopped start reads no ending. Router reads and
|
||||
// retirements reach the registered adapters a fixed number of times per open, whatever the
|
||||
// starts before it.
|
||||
expect(n.counts).toMatchObject({
|
||||
spawns: 6,
|
||||
opens: 6,
|
||||
codexEndingReads: 4,
|
||||
otherEndingReads: 4,
|
||||
codexRetirements: 6,
|
||||
otherRetirements: 6,
|
||||
failureRows: 2
|
||||
})
|
||||
for (const [field, count] of Object.entries(n.counts)) {
|
||||
expect(tenN.counts[field], field).toBe(count * 10)
|
||||
}
|
||||
})
|
||||
|
||||
/** Starts an ACP agent that prints a sign-in line Orca cannot read, `count` times. */
|
||||
async function refuseAcpStarts(count: number) {
|
||||
const connections: ReturnType<typeof createAcpAgentConnection>[] = []
|
||||
const refusals = new Map<ReturnType<typeof createAcpAgentConnection>, Error>()
|
||||
const rig = await openAcpAdapterRig({
|
||||
deps: {
|
||||
connect: (launch, options) => {
|
||||
const child = Object.assign(createFakePipedChild(), {
|
||||
pid: 9_999_999,
|
||||
stdout: new PassThrough(),
|
||||
stdin: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: () => true
|
||||
})
|
||||
// Orca's cleanup ends stdin; the agent then exits.
|
||||
child.stdin.once('finish', () => child.emit('exit', 0, null))
|
||||
const connection: ReturnType<typeof createAcpAgentConnection> = createAcpAgentConnection(
|
||||
launch,
|
||||
{
|
||||
...options,
|
||||
onClose: (error) => {
|
||||
if (!refusals.has(connection)) {
|
||||
refusals.set(connection, error)
|
||||
}
|
||||
options.onClose?.(error)
|
||||
},
|
||||
signIn: { read: readAntigravitySignInLine }
|
||||
},
|
||||
() => child
|
||||
)
|
||||
connections.push(connection)
|
||||
child.stdout.write('Open the following link to authenticate the ACP server: not-a-url\n')
|
||||
return connection
|
||||
}
|
||||
}
|
||||
})
|
||||
const retained: number[] = []
|
||||
try {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const error = await rig.acquire({ waitForStart: false }).catch((caught: unknown) => caught)
|
||||
const connection = connections[index]
|
||||
// The verdict is final and carries this connection's own first refusal, not a later close.
|
||||
const verdict = findInErrorChain(error, (cause) =>
|
||||
cause instanceof AgentSessionStartFailure ? cause : undefined
|
||||
)
|
||||
expect(verdict?.retry).toBe('never')
|
||||
expect(verdict?.cause).toBe(refusals.get(connection))
|
||||
expect(connection.lostBeforeExit).toBe(refusals.get(connection))
|
||||
expect(connection.exited).toBe(true)
|
||||
retained.push(retainedReceipts())
|
||||
}
|
||||
return { spawns: connections.length, retained }
|
||||
} finally {
|
||||
await rig.adapter.closeAll()
|
||||
await closeProviderTimelineRigs()
|
||||
}
|
||||
}
|
||||
|
||||
it('ACP: each refused start keeps its own cause and leaves no receipt or child, at N and 10N', async () => {
|
||||
vi.useRealTimers()
|
||||
for (const count of [10, 100]) {
|
||||
const { spawns, retained } = await refuseAcpStarts(count)
|
||||
expect(spawns).toBe(count)
|
||||
expect(retained).toEqual(Array(count).fill(0))
|
||||
}
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DESKTOP_BROWSER_BRIDGE_METHODS } from '../../../shared/desktop-browser-bridge-protocol'
|
||||
import { ALL_RPC_METHODS } from '../rpc/methods/rpc-method-registry'
|
||||
|
||||
describe('desktop browser bridge allowlist', () => {
|
||||
const registered = new Map<string, (typeof ALL_RPC_METHODS)[number]>(
|
||||
ALL_RPC_METHODS.map((method) => [method.name, method])
|
||||
)
|
||||
|
||||
it('names only real one-shot browser methods in the workspace tier', () => {
|
||||
for (const name of DESKTOP_BROWSER_BRIDGE_METHODS) {
|
||||
const method = registered.get(name)
|
||||
expect(method, name).toBeDefined()
|
||||
expect(name.startsWith('browser.'), name).toBe(true)
|
||||
expect(method?.permission, name).toBe('workspace')
|
||||
expect('stream' in (method ?? {}), name).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves out every browser method that touches desktop files, secrets or browser identity', () => {
|
||||
const excluded = [...registered.keys()].filter(
|
||||
(name) => name.startsWith('browser.') && !DESKTOP_BROWSER_BRIDGE_METHODS.has(name)
|
||||
)
|
||||
expect(excluded).toEqual(
|
||||
expect.arrayContaining([
|
||||
'browser.upload',
|
||||
'browser.download',
|
||||
'browser.pdf',
|
||||
'browser.exec',
|
||||
'browser.cookie.get',
|
||||
'browser.cookie.set',
|
||||
'browser.cookie.delete',
|
||||
'browser.network',
|
||||
'browser.clipboardRead',
|
||||
'browser.clipboardWrite',
|
||||
'browser.storage.local.get',
|
||||
'browser.profileImportFromBrowser',
|
||||
'browser.profileCreate',
|
||||
'browser.profileDelete',
|
||||
'browser.tabSetProfile',
|
||||
'browser.identity.set',
|
||||
'browser.setCredentials',
|
||||
'browser.setHeaders',
|
||||
'browser.intercept.enable',
|
||||
'browser.certificate.proceed',
|
||||
'browser.screencast',
|
||||
'browser.openUrl',
|
||||
'browser.clientHost.attach'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -174,113 +174,6 @@ describe('mobile subscribe integration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serializeMainTerminalBuffer returns an empty snapshot for an empty headless buffer', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
type HeadlessStateForTest = {
|
||||
emulator: {
|
||||
isAlternateScreen: boolean
|
||||
getSnapshot: (opts: { scrollbackRows?: number }) => {
|
||||
rehydrateSequences: string
|
||||
snapshotAnsi: string
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
}
|
||||
outputSequence: number
|
||||
writeChain: Promise<void>
|
||||
ownership: { settle: () => Promise<void>; owner: undefined }
|
||||
}
|
||||
const runtimePrivate = runtime as unknown as {
|
||||
headlessTerminals: Map<string, HeadlessStateForTest>
|
||||
}
|
||||
runtimePrivate.headlessTerminals.set('pty-empty', {
|
||||
emulator: {
|
||||
isAlternateScreen: false,
|
||||
getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 })
|
||||
},
|
||||
outputSequence: 17,
|
||||
writeChain: Promise.resolve(),
|
||||
ownership: { settle: async () => {}, owner: undefined }
|
||||
})
|
||||
|
||||
await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({
|
||||
data: '',
|
||||
cols: 90,
|
||||
rows: 30,
|
||||
seq: 17,
|
||||
source: 'headless',
|
||||
// Preserve destructive scrollback clear on non-alt-screen restore.
|
||||
alternateScreen: false
|
||||
})
|
||||
await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe resizes PTY to phone dims', async () => {
|
||||
const { runtime, ptySizes, resizes, notifications } = createRuntime()
|
||||
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||||
cols: 45,
|
||||
rows: 20
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }])
|
||||
expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }])
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when mode is desktop', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||||
cols: 45,
|
||||
rows: 20
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when no viewport provided', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileUnsubscribe restores PTY after debounce in auto mode', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('preserves previousDims across re-subscribes to same terminal', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Re-subscribe sees phone-fitted PTY dims but must retain the original desktop baseline.
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('preserves held-override baseline across resubscribe-after-indefinite-hold', async () => {
|
||||
// Indefinite-hold re-subscribe must inherit the retained desktop baseline for later Restore.
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
@@ -300,184 +193,7 @@ describe('mobile subscribe integration', () => {
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('clamps viewport to valid range', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 })
|
||||
})
|
||||
|
||||
describe('display mode', () => {
|
||||
it('defaults to auto', () => {
|
||||
const { runtime } = createRuntime()
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
|
||||
it('set/get round-trip', () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMobileDisplayMode', () => {
|
||||
it('desktop mode restores PTY when currently phone-fitted', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
expect(resizeEvents[0]).toMatchObject({
|
||||
cols: 150,
|
||||
rows: 40,
|
||||
displayMode: 'desktop',
|
||||
reason: 'apply-layout'
|
||||
})
|
||||
})
|
||||
|
||||
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
expect(resizeEvents[0]).toMatchObject({
|
||||
displayMode: 'phone',
|
||||
reason: 'apply-layout'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup paths', () => {
|
||||
it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
// Disconnect queues work; flush microtasks and zero-delay timers.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||||
expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('onClientDisconnected cancels pending restore timers', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('onPtyExit cleans up mobileSubscribers and pending timers', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
runtime.onPtyExit('pty-1', 0)
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
})
|
||||
|
||||
it('onPtyExit does not cancel timers for other PTYs', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
runtime.onPtyExit('pty-2', 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize listener system', () => {
|
||||
it('subscribe/unsubscribe lifecycle', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
const events: unknown[] = []
|
||||
const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e))
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
|
||||
const countBefore = events.length
|
||||
unsubscribe()
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(events.length).toBe(countBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExternalPtyResize', () => {
|
||||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
// Wait out the 500ms phone-to-desktop resize cascade suppression before reporting renderer geometry.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('uses lastRendererSize for previousCols on first subscribe', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
ptySizes.set('pty-1', { cols: 214, rows: 72 })
|
||||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('ignores reports while a mobile-fit override is in place', async () => {
|
||||
// Phone-fit echoes must not poison the desktop restore baseline; see docs/mobile-fit-hold.md.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
@@ -490,28 +206,6 @@ describe('mobile subscribe integration', () => {
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('still updates baseline once the override is cleared (real desktop resize)', async () => {
|
||||
// Once the override clears, legitimate renderer reports must update the baseline.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
runtime.onExternalPtyResize('pty-1', 130, 35)
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 130, rows: 35 })
|
||||
})
|
||||
|
||||
it('updates baseline mid-fit when desktop reports dims that differ from the override', async () => {
|
||||
// A newly visible desktop tab can report real geometry during phone fit; retain it for restore.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
@@ -528,45 +222,6 @@ describe('mobile subscribe integration', () => {
|
||||
})
|
||||
|
||||
describe('recordRendererGeometry (pty:reportGeometry IPC)', () => {
|
||||
it('refreshes subscriber baseline while a mobile-fit override is active', async () => {
|
||||
// Never-active terminals need measurement-only geometry to replace spawn-default restore sizes.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
ptySizes.set('pty-1', { cols: 80, rows: 24 })
|
||||
runtime.onExternalPtyResize('pty-1', 80, 24)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.recordRendererGeometry('pty-1', 214, 72)
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 214, rows: 72 })
|
||||
})
|
||||
|
||||
it('restores reported geometry captured before the first mobile subscribe', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
runtime.recordRendererGeometry('pty-1', 180, 50)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 180, rows: 50 })
|
||||
})
|
||||
|
||||
it('ignores non-positive dims when capturing restore geometry', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
runtime.recordRendererGeometry('pty-1', 180, 50)
|
||||
runtime.recordRendererGeometry('pty-1', 0, 0)
|
||||
runtime.recordRendererGeometry('pty-1', -5, 10)
|
||||
runtime.recordRendererGeometry('pty-1', 10, -5)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 180, rows: 50 })
|
||||
})
|
||||
|
||||
it('bypasses the cascade-suppress window (it is measurement-only)', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
@@ -589,78 +244,8 @@ describe('mobile subscribe integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old resizeForClient still works alongside new system', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(fitResult.mode).toBe('mobile-fit')
|
||||
|
||||
const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(restoreResult.mode).toBe('desktop-fit')
|
||||
})
|
||||
})
|
||||
|
||||
// See docs/mobile-fit-hold.md.
|
||||
describe('mobileAutoRestoreFitMs (fit hold)', () => {
|
||||
it('null (indefinite) keeps PTY at phone dims after last unsubscribe', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('finite ms restores after the configured delay', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('re-subscribe before timer fires cancels pending restore', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('reclaimTerminalForDesktop with active subscriber keeps desktop mode until the phone acts', async () => {
|
||||
// #28487: reconnect/resume re-subscribe stays passive; only typing takes the floor.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(ok).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(runtime.getDriver('pty-1').kind).not.toBe('mobile')
|
||||
|
||||
await runtime.mobileTookFloor('pty-1', 'client-a')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
|
||||
it('reclaim with WS disconnect between cycles — held branch restores correctly each time', async () => {
|
||||
// Repeated held-override reclaim must restore desktop geometry, never the current phone fit.
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
@@ -690,176 +275,5 @@ describe('mobile subscribe integration', () => {
|
||||
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('reclaim → re-subscribe → reclaim cycle works repeatedly', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.mobileTookFloor('pty-1', 'client-a')
|
||||
expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(ok, `iter ${i}: reclaim ok`).toBe(true)
|
||||
expect(ptySizes.get('pty-1'), `iter ${i}: reclaimed to desktop`).toEqual({
|
||||
cols: 150,
|
||||
rows: 40
|
||||
})
|
||||
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('null (indefinite) keeps PTY at phone dims when WS connection closes (onClientDisconnected)', async () => {
|
||||
// WS close bypasses handleMobileUnsubscribe but must still honor indefinite hold.
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reclaimTerminalForDesktop returns held PTY to desktop dims (no subscriber)', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
|
||||
})
|
||||
|
||||
it('reclaim cancels the soft-leave timer in the remote-layout branch', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-a', 'viewer-a', 120, 32)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
|
||||
})
|
||||
|
||||
it('reclaim cancels pending restore timers on the active-subscriber branch', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-b', { cols: 40, rows: 18 })
|
||||
|
||||
const pendingRestore = runtime.restoreTimers
|
||||
pendingRestore.set('pty-1', { timer: setTimeout(() => {}, 60_000), clientId: 'client-b' })
|
||||
const pendingSoft = runtime.softLeavers
|
||||
expect(pendingSoft.has('pty-1')).toBe(true)
|
||||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(pendingRestore.has('pty-1')).toBe(false)
|
||||
expect(pendingSoft.has('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaim cancels pending restore timers on the orphan-driver branch', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
runtime.fitOverrides.delete('pty-1')
|
||||
|
||||
const pendingRestore = runtime.restoreTimers
|
||||
const pendingSoft = runtime.softLeavers
|
||||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(pendingRestore.has('pty-1')).toBe(false)
|
||||
expect(pendingSoft.has('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaim cancels pending restore timers when no driver lock remains', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
runtime.fitOverrides.delete('pty-1')
|
||||
runtime.drivers.set('pty-1', { kind: 'idle' })
|
||||
|
||||
const pendingRestore = runtime.restoreTimers
|
||||
const pendingSoft = runtime.softLeavers
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
|
||||
expect(pendingRestore.has('pty-1')).toBe(false)
|
||||
expect(pendingSoft.has('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaim revokes soft-leave grace admission for mobile input', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
const claim = runtime.beginMobileInputFloor('pty-1', 'client-a')
|
||||
expect(claim).not.toBeNull()
|
||||
claim?.rollback()
|
||||
|
||||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
|
||||
expect(runtime.beginMobileInputFloor('pty-1', 'client-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('reclaimTerminalForDesktop prefers fresh desktop geometry for a held PTY', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.recordRendererGeometry('pty-1', 180, 50)
|
||||
|
||||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(ok).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 180, rows: 50 })
|
||||
})
|
||||
|
||||
it('setMobileAutoRestoreFitMs(null) clears all pending timers', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
runtime.setMobileAutoRestoreFitMs(null)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('setMobileAutoRestoreFitMs to a finite value does not retroactively schedule', async () => {
|
||||
settingsState.mobileAutoRestoreFitMs = null
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
// Finite preferences apply on the next unsubscribe, never retroactively to an existing hold.
|
||||
runtime.setMobileAutoRestoreFitMs(60_000)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('setMobileAutoRestoreFitMs clamps below MIN to MIN', () => {
|
||||
const { runtime } = createRuntime()
|
||||
const result = runtime.setMobileAutoRestoreFitMs(100)
|
||||
expect(result).toBe(5_000)
|
||||
})
|
||||
|
||||
it('setMobileAutoRestoreFitMs clamps above MAX to MAX', () => {
|
||||
const { runtime } = createRuntime()
|
||||
const result = runtime.setMobileAutoRestoreFitMs(99 * 60 * 60 * 1000)
|
||||
expect(result).toBe(60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('reclaimTerminalForDesktop on already-restored PTY returns false', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { RuntimeAutomationController } from './runtime-automation-controller'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
@@ -73,38 +72,6 @@ export function registerOrcaRuntimeAutomationsTests(): void {
|
||||
} satisfies Automation
|
||||
|
||||
describe('OrcaRuntimeService automation methods', () => {
|
||||
it('creates repo-scoped automations through the shared store', async () => {
|
||||
const store = makeStore()
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
const automation = await runtime.createAutomation({
|
||||
name: 'Daily review',
|
||||
prompt: 'Review changes',
|
||||
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
|
||||
agentId: 'codex',
|
||||
repo: 'repo-1',
|
||||
workspaceMode: 'new_per_run',
|
||||
setupDecision: 'skip',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 1
|
||||
})
|
||||
|
||||
expect(store.createAutomation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Daily review',
|
||||
prompt: 'Review changes',
|
||||
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
|
||||
agentId: 'codex',
|
||||
projectId: 'repo-1',
|
||||
workspaceMode: 'new_per_run',
|
||||
workspaceId: null,
|
||||
setupDecision: 'skip'
|
||||
}),
|
||||
{ destination: undefined, resolvedRepo: repo }
|
||||
)
|
||||
expect(automation.id).toBe('auto-1')
|
||||
})
|
||||
|
||||
it('rejects a run context that names a different repo path', async () => {
|
||||
const store = makeStore()
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
@@ -132,38 +99,6 @@ export function registerOrcaRuntimeAutomationsTests(): void {
|
||||
expect(store.createAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a mismatched run context when the workspace is the machine selector', async () => {
|
||||
const store = makeStore()
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({
|
||||
id: 'repo-1::/tmp/orca',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/orca'
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.createAutomation({
|
||||
name: 'Workspace review',
|
||||
prompt: 'Review changes',
|
||||
agentId: 'codex',
|
||||
workspace: 'id:repo-1::/tmp/orca',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/other/repo'
|
||||
},
|
||||
workspaceMode: 'existing',
|
||||
setupDecision: 'skip',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 1
|
||||
})
|
||||
).rejects.toThrow('Automation project does not match its run context.')
|
||||
expect(store.createAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a run-context-only update that names a different repo path', async () => {
|
||||
const store = makeStore([existingAutomation])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
@@ -182,169 +117,5 @@ export function registerOrcaRuntimeAutomationsTests(): void {
|
||||
).rejects.toThrow('Automation project does not match its run context.')
|
||||
expect(store.updateAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates and deletes existing automations through the shared store', async () => {
|
||||
const store = makeStore([existingAutomation])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
const updated = await runtime.updateAutomation('auto-1', { enabled: false })
|
||||
const removed = await runtime.deleteAutomation('auto-1')
|
||||
|
||||
expect(store.updateAutomation).toHaveBeenCalledWith(
|
||||
'auto-1',
|
||||
{ enabled: false },
|
||||
{ resolvedRepo: undefined }
|
||||
)
|
||||
expect(updated).toMatchObject({
|
||||
prompt: existingAutomation.prompt,
|
||||
baseBranch: existingAutomation.baseBranch,
|
||||
workspaceMode: existingAutomation.workspaceMode,
|
||||
enabled: false
|
||||
})
|
||||
expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined)
|
||||
expect(removed).toEqual({ removed: true, id: 'auto-1' })
|
||||
})
|
||||
|
||||
it('preserves explicit nullable fields in sparse automation updates', async () => {
|
||||
const store = makeStore([existingAutomation])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.updateAutomation('auto-1', { baseBranch: null })
|
||||
|
||||
expect(store.updateAutomation).toHaveBeenCalledWith(
|
||||
'auto-1',
|
||||
{ baseBranch: null },
|
||||
{ resolvedRepo: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes setup decision updates through the shared store', async () => {
|
||||
const store = makeStore([existingAutomation])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.updateAutomation('auto-1', { setupDecision: 'run' })
|
||||
|
||||
expect(store.updateAutomation).toHaveBeenCalledWith(
|
||||
'auto-1',
|
||||
{ setupDecision: 'run' },
|
||||
{ resolvedRepo: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes session reuse updates for existing-workspace automations', async () => {
|
||||
const existing = {
|
||||
...existingAutomation,
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'repo-1::/tmp/orca',
|
||||
baseBranch: null
|
||||
} satisfies Automation
|
||||
const store = makeStore([existing])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.updateAutomation('auto-1', { reuseSession: true })
|
||||
|
||||
expect(store.updateAutomation).toHaveBeenCalledWith(
|
||||
'auto-1',
|
||||
{ reuseSession: true },
|
||||
{ resolvedRepo: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('clears session reuse when retargeting to new-per-run', async () => {
|
||||
const existing = {
|
||||
...existingAutomation,
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'repo-1::/tmp/orca',
|
||||
baseBranch: null,
|
||||
reuseSession: true
|
||||
} satisfies Automation
|
||||
const store = makeStore([existing])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.updateAutomation('auto-1', {
|
||||
repo: 'repo-1',
|
||||
workspaceMode: 'new_per_run'
|
||||
})
|
||||
|
||||
expect(store.updateAutomation).toHaveBeenCalledWith(
|
||||
'auto-1',
|
||||
expect.objectContaining({
|
||||
projectId: 'repo-1',
|
||||
workspaceMode: 'new_per_run',
|
||||
workspaceId: null,
|
||||
reuseSession: false
|
||||
}),
|
||||
{ resolvedRepo: repo }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects session reuse for new-per-run automations', async () => {
|
||||
const store = makeStore()
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await expect(
|
||||
runtime.createAutomation({
|
||||
name: 'Fresh',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'codex',
|
||||
repo: 'repo-1',
|
||||
workspaceMode: 'new_per_run',
|
||||
reuseSession: true,
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 1
|
||||
})
|
||||
).rejects.toThrow('Session reuse requires an existing workspace target.')
|
||||
expect(store.createAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects repo-only updates for existing-workspace automations', async () => {
|
||||
const existing = {
|
||||
...existingAutomation,
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'repo-1::/tmp/orca-worktree',
|
||||
baseBranch: null
|
||||
} satisfies Automation
|
||||
const store = makeStore([existing])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await expect(runtime.updateAutomation('auto-1', { repo: 'repo-2' })).rejects.toThrow(
|
||||
'Repo updates for existing-workspace automation require workspaceMode new_per_run.'
|
||||
)
|
||||
expect(store.updateAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps generic automation reads on the current owner before invoking the reader', () => {
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const owner = runtime['automation']
|
||||
const result = { rows: ['run-1'] }
|
||||
const events: string[] = []
|
||||
const read = vi.fn(() => {
|
||||
events.push('read')
|
||||
return result
|
||||
})
|
||||
const priority = vi.spyOn(owner, 'withExternalProbePriority').mockImplementation(function <T>(
|
||||
this: RuntimeAutomationController,
|
||||
operation: () => T
|
||||
): T {
|
||||
expect(this).toBe(owner)
|
||||
expect(arguments.length).toBe(1)
|
||||
events.push('priority')
|
||||
return operation()
|
||||
})
|
||||
Object.defineProperty(runtime, 'automation', {
|
||||
get() {
|
||||
events.push('owner')
|
||||
return owner
|
||||
}
|
||||
})
|
||||
try {
|
||||
expect(runtime['readAutomationRuns'](undefined, undefined, read)).toBe(result)
|
||||
expect(events).toEqual(['owner', 'priority', 'read'])
|
||||
expect(priority).toHaveBeenCalledOnce()
|
||||
expect(read).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
priority.mockRestore()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,475 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection'
|
||||
import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
|
||||
import type { IPtyProvider } from '../../providers/pty-provider-contract'
|
||||
import {
|
||||
TEST_WORKTREE_ID,
|
||||
createExplicitAgentStatusHarness,
|
||||
deferred,
|
||||
store
|
||||
} from './orca-runtime-test-fixtures.spec'
|
||||
import '../orca-runtime'
|
||||
import '../../providers/pty-process-inspection'
|
||||
import '../../../shared/workspace-layout/stable-pane-id'
|
||||
|
||||
import { createExplicitAgentStatusHarness, deferred } from './orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('prefers newer explicit working state over older explicit permission state', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'waiting',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: now - 1000,
|
||||
stateStartedAt: now - 1000,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
},
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers fresh explicit working state over a stale permission title', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex - action required',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports permission from a live title over fresh explicit working state', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1',
|
||||
paneTitle: 'Codex - action required'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let fresh explicit hook state authorize a current shell terminal', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'zsh',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let fresh explicit hook state authorize a shell foreground process', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'claude')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('preserves provider failure during completion-sensitive process inspection', async () => {
|
||||
const failure = new Error('daemon unavailable')
|
||||
const providerInspectProcess = vi.fn().mockRejectedValue(failure)
|
||||
const provider = { inspectProcess: providerInspectProcess } as unknown as IPtyProvider
|
||||
const inspectProcess = vi.fn((ptyId: string) => inspectPtyProviderProcess(provider, ptyId))
|
||||
const getForegroundProcess = vi.fn(async () => null)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
inspectProcess
|
||||
})
|
||||
|
||||
await expect(runtime.inspectTerminalProcess(handle)).rejects.toBe(failure)
|
||||
expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(providerInspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves provider unavailable results during process inspection', async () => {
|
||||
const inspection = {
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: true,
|
||||
unavailable: true as const
|
||||
}
|
||||
const inspectProcess = vi.fn(async () => inspection)
|
||||
const getForegroundProcess = vi.fn(async () => null)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
inspectProcess
|
||||
})
|
||||
|
||||
await expect(runtime.inspectTerminalProcess(handle)).resolves.toEqual(inspection)
|
||||
expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls foreground confirmation with its controller receiver', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(async function (this: {
|
||||
getForegroundProcess: typeof getForegroundProcess
|
||||
}) {
|
||||
return this.getForegroundProcess === getForegroundProcess ? 'codex' : null
|
||||
})
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['shell', async () => 'pwsh.exe'],
|
||||
['non-agent', async () => 'vim'],
|
||||
['unavailable', async () => null],
|
||||
[
|
||||
'failure',
|
||||
async () => {
|
||||
throw new Error('provider unavailable')
|
||||
}
|
||||
]
|
||||
])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => {
|
||||
const confirmForegroundProcess = vi.fn(confirm)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed on a shell conflict when the controller cannot confirm it', async () => {
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'codex')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips both foreground reads when current title evidence blocks explicit hook state', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess,
|
||||
title: 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips foreground reads for permission title and blocked wait evidence', async () => {
|
||||
for (const blocked of ['title', 'wait'] as const) {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
blocked === 'title'
|
||||
? '\x1b]0;Codex waiting for permission\x07'
|
||||
: 'Hooks need review. Press enter to confirm\n',
|
||||
Date.now() + 1000
|
||||
)
|
||||
getForegroundProcess.mockClear()
|
||||
confirmForegroundProcess.mockClear()
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => {
|
||||
const foreground = deferred<string | null>()
|
||||
const getForegroundProcess = vi.fn(() => foreground.promise)
|
||||
@@ -531,99 +68,4 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_exited')
|
||||
})
|
||||
|
||||
it('reports permission from a title-derived action-required agent state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex waiting for permission',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps fresh explicit done hook state to idle for send readiness', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'idle'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { committingSpawn } from './bind-first-pane.test-fixture'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
|
||||
import '../../../shared/workspace-layout/stable-pane-id'
|
||||
import type { RuntimeTerminalAgentStatusEvent } from '../terminal/output/terminal-agent-status-events'
|
||||
import {
|
||||
TEST_WORKTREE_ID,
|
||||
@@ -107,116 +107,6 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reads bounded terminal output and writes through the PTY controller', async () => {
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', '\u001b[32mhello\u001b[0m\nworld\n', 123)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
expect(read).toMatchObject({
|
||||
handle: terminal.handle,
|
||||
status: 'running',
|
||||
tail: ['hello', 'world'],
|
||||
truncated: false,
|
||||
nextCursor: expect.any(String)
|
||||
})
|
||||
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true })
|
||||
try {
|
||||
const [send] = await Promise.all([
|
||||
runtime.sendTerminal(
|
||||
terminal.handle,
|
||||
{
|
||||
text: 'continue',
|
||||
enter: true
|
||||
},
|
||||
{ inputKind: 'driving' }
|
||||
),
|
||||
vi.runAllTimersAsync()
|
||||
])
|
||||
expect(send).toMatchObject({
|
||||
handle: terminal.handle,
|
||||
accepted: true
|
||||
})
|
||||
expect(writes).toEqual(['continue', '\r'])
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports permission from blocked terminal wait text', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. Press enter to confirm\n', 123)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps blocked prompt text authoritative over an OpenCode marker', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
@@ -267,370 +157,4 @@ describe('OrcaRuntimeService', () => {
|
||||
blockedReason: 'agent-interactive-prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports permission from blocked wait text over title-only working state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex working',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. Press enter to confirm\n', 123)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a live non-permission title supersede stale blocked wait text', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. Press enter to confirm\n', 123)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 124)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps fresh explicit waiting hook state to permission over a working title', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'waiting',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex working',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
// A restored permission wait is the exception; agent-prompt-restored-permission.test.ts covers it (#35962).
|
||||
it('does not treat a restored-unconfirmed hook row as live terminal status', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
restoredUnconfirmed: true
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let stale wait text override a fresh explicit working state', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex working',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. Press enter to confirm\n', 123)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports permission when blocked wait text is newer than explicit working state', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: now - 1000,
|
||||
stateStartedAt: now - 1000,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex working',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. Press enter to confirm\n', now)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
|
||||
it('timestamps blocked wait text when the prompt arrives across PTY chunks', async () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex working',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'Hooks need review. ', now + 1000)
|
||||
runtime.onPtyData('pty-1', 'Press enter to confirm\n', now + 1001)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import {
|
||||
TEST_REPO_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_PATH,
|
||||
store
|
||||
} from './orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('rejects a qualified removal when only another host has persisted ownership', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' })
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
listResolvedWorktrees: () => Promise<unknown[]>
|
||||
resolveWorktreeRemovalTarget: (
|
||||
worktreeSelector: string,
|
||||
requiredHostId?: string
|
||||
) => Promise<unknown>
|
||||
}
|
||||
internals.listResolvedWorktrees = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: TEST_WORKTREE_ID,
|
||||
repoId: TEST_REPO_ID,
|
||||
path: TEST_WORKTREE_PATH,
|
||||
hostId: 'local'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
internals.resolveWorktreeRemovalTarget(TEST_WORKTREE_ID, 'runtime:env-b')
|
||||
).rejects.toThrow('selector_not_found')
|
||||
expect(internals.listResolvedWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { deleteWorktreeHistoryDirMock } from '../orca-runtime-test-mocks/setup.spec'
|
||||
import '../orca-runtime-test-mocks/setup.spec'
|
||||
import { getBrowserHostLeaseRegistry } from '../browser-host/leases/browser-host-lease-registry-instance'
|
||||
import { getRuntimeBrowserPageRegistry } from '../browser/client-pages/runtime-browser-page-registry'
|
||||
import {
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
attachClientBrowserHost,
|
||||
publishClientHostedPage
|
||||
} from './orca-runtime-test-scenario-builders.spec'
|
||||
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
|
||||
import '../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('preserves a live client page across headed renderer graph updates and prunes it after retirement', async () => {
|
||||
@@ -164,71 +164,4 @@ describe('OrcaRuntimeService', () => {
|
||||
placement
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves bare-id runtime state when removing a different qualified owner', () => {
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' }),
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, unknown>
|
||||
removeWorktreeMetadataAndHistory: (
|
||||
runtimeStore: typeof store,
|
||||
worktreeId: string,
|
||||
hostId: string
|
||||
) => void
|
||||
}
|
||||
const localSession = { tabs: [{ id: 'local-tab' }] }
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, localSession)
|
||||
|
||||
internals.removeWorktreeMetadataAndHistory(
|
||||
runtimeStore as typeof store,
|
||||
TEST_WORKTREE_ID,
|
||||
'runtime:env-b'
|
||||
)
|
||||
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'runtime:env-b' })
|
||||
)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(localSession)
|
||||
})
|
||||
|
||||
it('preserves bare-id runtime state when removed-host metadata masks another owner', () => {
|
||||
const localRepo = store.getRepo(TEST_REPO_ID)!
|
||||
const remoteRepo = { ...localRepo, connectionId: 'ssh-1' }
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo, remoteRepo],
|
||||
getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'ssh:ssh-1' }),
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const internals = runtime as unknown as {
|
||||
mobileSessionTabsByWorktree: Map<string, unknown>
|
||||
removeWorktreeMetadataAndHistory: (
|
||||
runtimeStore: typeof store,
|
||||
worktreeId: string,
|
||||
hostId: string
|
||||
) => void
|
||||
}
|
||||
const survivingSession = { tabs: [{ id: 'same-id-local-tab' }] }
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, survivingSession)
|
||||
deleteWorktreeHistoryDirMock.mockClear()
|
||||
|
||||
internals.removeWorktreeMetadataAndHistory(
|
||||
runtimeStore as typeof store,
|
||||
TEST_WORKTREE_ID,
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'ssh:ssh-1' })
|
||||
)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(survivingSession)
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,16 +63,4 @@ describe('OrcaRuntimeService ConPTY stream reads', () => {
|
||||
clearNativeWindowsConptyPty(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps accumulated-stream reads for terminals that are not ConPTY', async () => {
|
||||
const ptyId = 'pty-posix-prediction'
|
||||
const runtime = startConptyRuntime(ptyId)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData(ptyId, 'one\r\ntwo\rTWO\r\n', 100)
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read.tail).toEqual(['one', 'TWO'])
|
||||
expect(read.source).toBe('stream')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ await import('./orca-runtime-tests/dedupe-worktree-create.spec')
|
||||
await import('./orca-runtime-tests/paired-settings.spec')
|
||||
await import('./orca-runtime-tests/runtime-availability.spec')
|
||||
await import('./orca-runtime-tests/browser-capabilities.spec')
|
||||
await import('./orca-runtime-tests/browser-capabilities-part-02.spec')
|
||||
await import('./orca-runtime-tests/managed-server-capability.spec')
|
||||
await import('./orca-runtime-tests/window-authority.spec')
|
||||
await import('./orca-runtime-tests/terminal-handles.spec')
|
||||
|
||||
@@ -49,12 +49,6 @@ describe('worktree removal drops the workspace trust Orca wrote', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the recorded entry before the purge resolves', async () => {
|
||||
await purgeWithMeta()(WORKTREE_ID)
|
||||
|
||||
expect(readFileSync(configPath, 'utf-8')).not.toContain(`[projects."${PROJECT}"]`)
|
||||
})
|
||||
|
||||
it('removes only the Antigravity trustedWorkspaces entry Orca appended', async () => {
|
||||
const agyHome = join(userDataDir, 'agy-home')
|
||||
const settingsPath = join(agyHome, '.gemini', 'antigravity-cli', 'settings.json')
|
||||
@@ -73,19 +67,6 @@ describe('worktree removal drops the workspace trust Orca wrote', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a WSL guest entry recorded under the host workspace path', async () => {
|
||||
const agyHome = join(userDataDir, 'agy-home')
|
||||
const settingsPath = join(agyHome, '.gemini', 'antigravity-cli', 'settings.json')
|
||||
markAntigravityWorkspaceTrusted(PROJECT, agyHome, '/home/u/feature-r1')
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).trustedWorkspaces).toEqual([
|
||||
'/home/u/feature-r1'
|
||||
])
|
||||
|
||||
await purgeWithMeta()(WORKTREE_ID)
|
||||
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).trustedWorkspaces).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an Antigravity entry that was trusted before Orca saw the path', async () => {
|
||||
const agyHome = join(userDataDir, 'agy-home')
|
||||
const settingsPath = join(agyHome, '.gemini', 'antigravity-cli', 'settings.json')
|
||||
@@ -99,18 +80,4 @@ describe('worktree removal drops the workspace trust Orca wrote', () => {
|
||||
trustedWorkspaces: [PROJECT]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the folder trust when one folder-workspace session is removed', async () => {
|
||||
const session = `${WORKTREE_ID}::workspace:5e8cc6b2-1d3f-4a5b-9c2d-7f6e5a4b3c2d`
|
||||
await purgeWithMeta()(session, undefined, false)
|
||||
await purgeWithMeta()(session)
|
||||
|
||||
expect(readFileSync(configPath, 'utf-8')).toContain(`[projects."${PROJECT}"]`)
|
||||
})
|
||||
|
||||
it('keeps the entry while another host still owns the same path-derived id', async () => {
|
||||
await purgeWithMeta({ hostId: 'ssh:host-b' })(WORKTREE_ID, 'ssh:host-a')
|
||||
|
||||
expect(readFileSync(configPath, 'utf-8')).toContain(`[projects."${PROJECT}"]`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,117 +149,4 @@ describe('Azure DevOps hosted review integration', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('prefers an active Azure Repos PR over a newer abandoned PR for the same branch', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`)
|
||||
seen.push({
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
authorization: req.headers.authorization
|
||||
})
|
||||
|
||||
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
|
||||
sendJson(res, {
|
||||
id: 'repo-guid',
|
||||
webUrl: 'https://dev.azure.com/acme/Project/_git/repo'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
|
||||
sendJson(res, {
|
||||
value: [
|
||||
{
|
||||
pullRequestId: 40,
|
||||
title: 'Abandoned branch',
|
||||
status: 'abandoned',
|
||||
creationDate: '2026-05-10T00:00:00Z',
|
||||
closedDate: '2026-05-20T00:00:00Z',
|
||||
mergeStatus: 'conflicts',
|
||||
lastMergeSourceCommit: { commitId: 'old123' }
|
||||
},
|
||||
{
|
||||
pullRequestId: 41,
|
||||
title: 'Active branch',
|
||||
status: 'active',
|
||||
creationDate: '2026-05-01T00:00:00Z',
|
||||
mergeStatus: 'succeeded',
|
||||
lastMergeSourceCommit: { commitId: 'active123' }
|
||||
}
|
||||
]
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/40/statuses'
|
||||
) {
|
||||
sendJson(res, { value: [{ state: 'failed' }] })
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses'
|
||||
) {
|
||||
sendJson(res, { value: [{ state: 'succeeded' }] })
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ message: 'not found' }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
const repoPath = await mkdtemp(join(tmpdir(), 'orca-azure-review-active-'))
|
||||
try {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('expected TCP server address')
|
||||
}
|
||||
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = `http://127.0.0.1:${address.port}/acme/Project`
|
||||
|
||||
await execFileAsync('git', ['init'], { cwd: repoPath })
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['remote', 'add', 'origin', 'https://dev.azure.com/acme/Project/_git/repo'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
||||
await expect(
|
||||
getHostedReviewForBranch({
|
||||
executionHostId: 'local',
|
||||
repoPath,
|
||||
branch: 'refs/heads/feature/azure'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'azure-devops',
|
||||
number: 41,
|
||||
title: 'Active branch',
|
||||
state: 'open',
|
||||
status: 'success',
|
||||
checksSummary: {
|
||||
state: 'success',
|
||||
total: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
},
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'active123'
|
||||
})
|
||||
|
||||
expect(seen.map((request) => request.pathname)).toContain(
|
||||
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses'
|
||||
)
|
||||
} finally {
|
||||
await rm(repoPath, { recursive: true, force: true })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
|
||||
|
||||
vi.mock('../host/native-chat-visual-guest-theme', () => ({
|
||||
installNativeChatVisualGuestTheme: vi.fn()
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachGuestPolicies: vi.fn(),
|
||||
installNavigationPolicy: vi.fn(),
|
||||
isAllowedPartition: vi.fn(),
|
||||
attachRouteGuest: vi.fn(),
|
||||
installJavaScriptDialogs: vi.fn(),
|
||||
registerPluginGuard: vi.fn(),
|
||||
admitPluginFrame: vi.fn(),
|
||||
installPluginFramePolicies: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: { attachGuestPolicies: mocks.attachGuestPolicies }
|
||||
}))
|
||||
vi.mock('../browser/sessions/browser-session-registry', () => ({
|
||||
browserSessionRegistry: { isAllowedPartition: mocks.isAllowedPartition }
|
||||
}))
|
||||
vi.mock('./host-frame-navigation-guard', () => ({
|
||||
registerHostFrameNavigationGuard: mocks.registerPluginGuard
|
||||
}))
|
||||
vi.mock('./privileged-window-navigation', () => ({
|
||||
installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy
|
||||
}))
|
||||
vi.mock('../browser/routing/sessions/browser-route-session-runtime', () => ({
|
||||
browserRouteSessionRegistry: { isAllowedPartition: () => false },
|
||||
browserRouteWebContentsRegistry: { attachGuest: mocks.attachRouteGuest }
|
||||
}))
|
||||
vi.mock('../browser/routing/local-host-partitions/local-ssh-browser-partitions', () => ({
|
||||
isLocalSshBrowserPartition: () => false,
|
||||
enforceLocalSshWebRtcPolicyForGuest: vi.fn()
|
||||
}))
|
||||
vi.mock('../browser/extensions/profile-sessions/browser-extension-session-loader', () => ({
|
||||
getBrowserExtensionSessionForProfile: () => null
|
||||
}))
|
||||
vi.mock('../browser/guest-lifecycle/prompts/browser-javascript-dialogs', () => ({
|
||||
installBrowserJavaScriptDialogs: mocks.installJavaScriptDialogs
|
||||
}))
|
||||
vi.mock(import('../plugins/panels/plugin-frame-guest-registry'), async (importOriginal) => ({
|
||||
PLUGIN_FRAME_GUEST_RENDERER_ARGUMENTS: (await importOriginal())
|
||||
.PLUGIN_FRAME_GUEST_RENDERER_ARGUMENTS,
|
||||
admitPluginFrameAttach: mocks.admitPluginFrame,
|
||||
isPluginFramePartition: (partition: string) => partition.startsWith('persist:orca-plugin-'),
|
||||
attachPluginFrameGuest: (guest: { session?: unknown }, host: unknown) => {
|
||||
if (guest.session !== 'plugin-session') {
|
||||
return false
|
||||
}
|
||||
mocks.installPluginFramePolicies(guest, host)
|
||||
return true
|
||||
}
|
||||
}))
|
||||
vi.mock('../browser-passwords/browser-password-guest-service', () => ({
|
||||
attachBrowserPasswordGuest: vi.fn()
|
||||
}))
|
||||
vi.mock('../browser/document-preview/doc-preview-protocol', () => ({
|
||||
isDocPreviewSession: (candidate: unknown) => candidate === 'doc-preview-session'
|
||||
}))
|
||||
|
||||
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/document-preview/doc-preview-grant-registry'
|
||||
import {
|
||||
publishDocPreviewFailure,
|
||||
setDocPreviewFailureSink
|
||||
} from '../browser/document-preview/doc-preview-failure-notice'
|
||||
import { buildDocPreviewUrl, DOC_PREVIEW_PARTITION } from '../../shared/doc-preview-scheme'
|
||||
|
||||
function installOnFakeWindow(): {
|
||||
handlers: Record<string, (...args: never[]) => void>
|
||||
webContents: { on: ReturnType<typeof vi.fn>; setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn> }
|
||||
} {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
}),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
return { handlers, webContents }
|
||||
}
|
||||
|
||||
function mintPreviewGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
describe('main window webview security', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
it('fails closed before applying hardened guest preferences', () => {
|
||||
const { handlers, webContents } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: 'persist:untrusted', preload: 'attacker.js' } as never,
|
||||
{ src: 'https://example.com', preload: 'attacker.js' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(mocks.installNavigationPolicy).toHaveBeenCalledWith(webContents)
|
||||
expect(mocks.registerPluginGuard).toHaveBeenCalledWith(webContents)
|
||||
expect(webContents.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
it('removes renderer preload input and restores every hardened preference', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const params = { src: 'https://example.com', preload: 'attacker.js' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: 'persist:orca-browser',
|
||||
preload: 'attacker.js',
|
||||
preloadURL: 'attacker.js',
|
||||
sandbox: false
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
params as never
|
||||
)
|
||||
|
||||
expect(params).not.toHaveProperty('preload')
|
||||
expect(preferences).toMatchObject({
|
||||
...ORCA_BROWSER_GUEST_WEB_PREFERENCES,
|
||||
partition: 'persist:orca-browser',
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: true,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
})
|
||||
expect(preferences).not.toHaveProperty('preloadURL')
|
||||
expect(String(preferences.preload)).toMatch(/browser-guest-preload\.js$/)
|
||||
})
|
||||
|
||||
it('runs preloads in subframes of every local profile tab, so extensions switched on later reach it', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: 'persist:orca-browser-work',
|
||||
nodeIntegrationInSubFrames: false
|
||||
}
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
{ src: 'https://example.com' } as never
|
||||
)
|
||||
|
||||
expect(preferences.nodeIntegrationInSubFrames).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca-preview scheme admission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
it('admits a preview URL only on the preview partition and only with a live grant', () => {
|
||||
// Install first, as the window does: installation itself clears the registry.
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
preload: 'attacker.js',
|
||||
sandbox: false
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
preferences as never,
|
||||
{ src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(preferences).toMatchObject({
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
})
|
||||
expect(preferences).not.toHaveProperty('preloadURL')
|
||||
})
|
||||
|
||||
// Only preview guests get the pinned link preload; browsing guests must never acquire it and renderer values must never replace it.
|
||||
it('pins the preview preload onto a preview attach, replacing whatever the renderer asked for', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const params = { src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
preload: 'attacker.js'
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
params as never
|
||||
)
|
||||
|
||||
expect(params).not.toHaveProperty('preload')
|
||||
expect(String(preferences.preload)).toMatch(/doc-preview-link-preload\.js$/)
|
||||
})
|
||||
|
||||
it('keeps the preview preload off a browsing attach', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const preferences: Record<string, unknown> = { partition: 'persist:orca-browser' }
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
{ src: 'https://example.com' } as never
|
||||
)
|
||||
|
||||
expect(String(preferences.preload)).toMatch(/browser-guest-preload\.js$/)
|
||||
})
|
||||
|
||||
it('denies a preview URL whose grant is unknown or revoked', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: DOC_PREVIEW_PARTITION } as never,
|
||||
{ src: `orca-preview://${'0'.repeat(32)}/index.html` } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('denies a preview URL smuggled onto a browsing partition', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
// Even an allowlisted browsing partition must not load the preview scheme.
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: 'persist:orca-browser' } as never,
|
||||
{ src: buildDocPreviewUrl(grant.id, 'index.html') } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('denies a web URL on the preview partition', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: DOC_PREVIEW_PARTITION } as never,
|
||||
{ src: 'https://example.com' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// The granting renderer is the only valid click sink; verify this window hosts the preview, not merely its profile.
|
||||
it('attaches a preview guest under the workspace-doc profile, hosted by this window', () => {
|
||||
const { handlers, webContents } = installOnFakeWindow()
|
||||
|
||||
handlers['did-attach-webview']?.({} as never, { session: 'doc-preview-session' } as never)
|
||||
|
||||
expect(mocks.attachGuestPolicies).toHaveBeenCalledWith(
|
||||
{ session: 'doc-preview-session' },
|
||||
null,
|
||||
{
|
||||
profile: 'workspace-doc',
|
||||
host: webContents
|
||||
}
|
||||
)
|
||||
expect(mocks.attachRouteGuest).not.toHaveBeenCalled()
|
||||
expect(mocks.installJavaScriptDialogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps browser guests on the browsing profile and its route registration', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
|
||||
handlers['did-attach-webview']?.({} as never, { session: 'browser-session' } as never)
|
||||
|
||||
expect(mocks.attachGuestPolicies).toHaveBeenCalledOnce()
|
||||
expect(mocks.attachGuestPolicies.mock.calls[0]?.[2]).toBeUndefined()
|
||||
expect(mocks.attachRouteGuest).toHaveBeenCalledOnce()
|
||||
expect(mocks.installJavaScriptDialogs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// WebContents teardown leaves no preview readers; clear grants and the dead failure sink.
|
||||
it('drops the failure sink and every grant when the window contents are destroyed', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
|
||||
|
||||
handlers['destroyed']?.()
|
||||
publishDocPreviewFailure({
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A renderer owns grant-to-preview bookkeeping; replacing it must revoke otherwise unreleasable read authority.
|
||||
it('clears grants a previous renderer left behind when the window is created', () => {
|
||||
const stranded = mintPreviewGrant()
|
||||
|
||||
installOnFakeWindow()
|
||||
|
||||
expect(getDocPreviewGrant(stranded.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears grants the renderer forgot across a reload', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
|
||||
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: false } as never)
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps grants across an in-document or subframe navigation, which keeps the renderer', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
|
||||
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: true } as never)
|
||||
handlers['did-start-navigation']?.({ isMainFrame: false, isSameDocument: false } as never)
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin frame guests', () => {
|
||||
const partition = 'persist:orca-plugin-0123456789abcdef'
|
||||
const src = 'orca-plugin-frame://0123456789abcdef/panel.html'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('refuses a plugin partition main did not prepare for that origin', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.admitPluginFrame.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
const preferences: Record<string, unknown> = { partition, preload: 'attacker.js' }
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
preferences as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only params.src.
|
||||
{ src } as never
|
||||
)
|
||||
|
||||
expect(mocks.admitPluginFrame).toHaveBeenCalledWith(partition, src)
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(preferences.preload).toBe('attacker.js')
|
||||
})
|
||||
|
||||
it('pins the guest preload and every secure preference, whatever the markup asked', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.admitPluginFrame.mockReturnValue(true)
|
||||
const params: Record<string, unknown> = { src, preload: 'attacker.js', allowpopups: 'true' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition,
|
||||
preload: 'attacker.js',
|
||||
preloadURL: 'attacker.js',
|
||||
additionalArguments: ['--x'],
|
||||
sandbox: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
disablePopups: false,
|
||||
experimentalFeatures: true,
|
||||
enableWebSQL: true,
|
||||
zoomFactor: 1.25
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
params as never
|
||||
)
|
||||
|
||||
expect(params).not.toHaveProperty('preload')
|
||||
expect(params).not.toHaveProperty('allowpopups')
|
||||
expect(preferences).not.toHaveProperty('preloadURL')
|
||||
// Anything main did not pin is dropped; zoom still follows the host window.
|
||||
expect(preferences).not.toHaveProperty('experimentalFeatures')
|
||||
expect(preferences).not.toHaveProperty('enableWebSQL')
|
||||
expect(preferences.zoomFactor).toBe(1.25)
|
||||
// Markup's arguments are replaced by main's renderer pins, never merged.
|
||||
expect(preferences.additionalArguments).toEqual([
|
||||
'--blink-settings=dnsPrefetchingEnabled=false'
|
||||
])
|
||||
expect(String(preferences.preload)).toMatch(/plugin-frame-guest-preload\.js$/)
|
||||
expect(preferences).toMatchObject({
|
||||
partition,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: false,
|
||||
disablePopups: true,
|
||||
disableDialogs: true
|
||||
})
|
||||
expect(mocks.isAllowedPartition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives attached plugin guests their own policy, never the browser guest one', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const guest = { session: 'plugin-session' }
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only guest.session.
|
||||
handlers['did-attach-webview']?.({} as never, guest as never)
|
||||
|
||||
expect(mocks.installPluginFramePolicies).toHaveBeenCalledWith(guest, expect.anything())
|
||||
expect(mocks.attachGuestPolicies).not.toHaveBeenCalled()
|
||||
expect(mocks.installJavaScriptDialogs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user