mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
rm git shim: neutralize stale wrappers without a host gate (#14255)
* Revert "fix terminal attribution shim removal edge cases (#14187)"
This reverts 585dd6d3a9. Re-landed in the next commit without the host capability gate. Nothing shipped with it, so no migration constraint.
* rm git shim: neutralize stale wrappers without a host gate
Re-lands the cleanup half of #14187: pass-through tombstones for retained wrapper paths, env/PATH scrubbing at every spawn owner, and the retired setting drop.
Only writes tombstones when the legacy directory already exists, so a clean install no longer has it created. Leaves out the terminal.attribution-removed.v1 capability gate: the tombstone neutralizes each host locally, so refusing terminal create/split against older hosts denied service without adding cleanup.
* rm git shim: surface neutralization failures and fix rollback marker
Readiness review follow-ups: warn on each failed attempt and on give-up (was silent and undiagnosable); write a VERSION marker distinct from the retired shim's '7' so a rolled-back build rewrites its own wrappers; clear a captured ORCA_REAL_* path that no longer exists so the cmd wrapper's where.exe fallback can run; stop a locked temp file masking the real error. Adds retry-exhaustion coverage.
* rm git shim: pin the cmd fallback order and correct the give-up count
Round-2 review follow-ups: string-pin that a stale ORCA_REAL_* is cleared before the where.exe fallback, and count the initial attempt in the give-up warning so it agrees with the per-attempt line.
* rm git shim: keep the split-failure toast
The revert took a toast that #14187 added alongside the gate but which stands on its own: without it a failed remote split only reaches the console and the pane silently never appears. Also pins attempt ordinals in the retry-exhaustion test.
This commit is contained in:
@@ -127,15 +127,6 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
|
||||
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../../../src/shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
assertMobileTerminalAttributionDisableSupported,
|
||||
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
|
||||
} from '../../../../src/session/mobile-terminal-attribution-compat'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
|
||||
import {
|
||||
@@ -3674,27 +3665,20 @@ export default function SessionScreen() {
|
||||
.slice(2, 10)}`
|
||||
|
||||
try {
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const response = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(options?.startupCommand ? { command: options.startupCommand } : {}),
|
||||
...(options?.startupCommandDelivery
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
|
||||
)
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(options?.startupCommand ? { command: options.startupCommand } : {}),
|
||||
...(options?.startupCommandDelivery
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as TerminalCreateResult
|
||||
const created = result.tab
|
||||
@@ -3794,17 +3778,10 @@ export default function SessionScreen() {
|
||||
showToast(message, 1800)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : null
|
||||
const message =
|
||||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
? errorMessage
|
||||
: (options?.errorToast ?? errorMessage ?? 'Failed to create terminal')
|
||||
} catch {
|
||||
const message = options?.errorToast ?? 'Failed to create terminal'
|
||||
setCreateError(message)
|
||||
if (
|
||||
options?.errorToast ||
|
||||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
) {
|
||||
if (options?.errorToast) {
|
||||
triggerError()
|
||||
showToast(message, 1800)
|
||||
}
|
||||
|
||||
@@ -226,13 +226,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it('creates a fresh terminal and sends the command with Enter', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
@@ -256,20 +249,13 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
navigation: 'caller'
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
|
||||
timeoutMs: 30_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
1,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: 'id:worktree-1',
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:3000',
|
||||
ORCA_ATTRIBUTION_BYPASS: '1'
|
||||
},
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
@@ -283,14 +269,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
},
|
||||
// Why: a socket drop mid-resume must reject within the request timeout
|
||||
// instead of parking on the reconnect waiter with the spinner pinned.
|
||||
{
|
||||
timeoutMs: RESUME_RPC_TIMEOUT_MS,
|
||||
budgetSpansConnect: true,
|
||||
expectedRuntimeId: 'runtime'
|
||||
}
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
@@ -302,35 +284,17 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
})
|
||||
|
||||
it('throws when terminal creation fails', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'no terminal' }
|
||||
})
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'no terminal' }
|
||||
})
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('no terminal')
|
||||
})
|
||||
|
||||
it('throws when the created terminal response is malformed', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('Created terminal response was invalid')
|
||||
@@ -339,13 +303,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it('throws when terminal send fails or is locked', async () => {
|
||||
const failedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
@@ -359,13 +316,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
|
||||
const lockedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
@@ -377,17 +327,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
})
|
||||
).rejects.toThrow('Terminal input is locked')
|
||||
})
|
||||
|
||||
it('refuses an old host before creating a resume terminal', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, result: { appVersion: '1.4.89' } })
|
||||
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('Update the host and try again')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMobileAiVaultResumeMutationRegistry', () => {
|
||||
|
||||
@@ -16,10 +16,6 @@ import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-res
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
readMobileReviewCreatedTerminal,
|
||||
@@ -27,7 +23,6 @@ import {
|
||||
type MobileReviewTerminalTab
|
||||
} from './mobile-diff-review-rpc'
|
||||
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
|
||||
import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat'
|
||||
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
@@ -174,13 +169,12 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const created = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(launch.env),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete),
|
||||
...(launch.env ? { env: launch.env } : {}),
|
||||
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
|
||||
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
|
||||
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
|
||||
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
|
||||
@@ -188,11 +182,7 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{
|
||||
timeoutMs: RESUME_RPC_TIMEOUT_MS,
|
||||
budgetSpansConnect: true,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
}
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!created.ok) {
|
||||
throw new Error(created.error?.message || 'Failed to create terminal')
|
||||
|
||||
@@ -53,14 +53,6 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
appVersion: '1.4.90',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
@@ -72,12 +64,9 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
|
||||
|
||||
expect(prepared).toBe(legacy)
|
||||
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
|
||||
})
|
||||
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
3,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat'
|
||||
|
||||
function status(result: unknown) {
|
||||
return {
|
||||
id: 'status',
|
||||
ok: true as const,
|
||||
result: { runtimeId: 'runtime', ...(result as object) },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile terminal attribution compatibility', () => {
|
||||
it('refuses hosts before terminal creation environment forwarding', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(
|
||||
status({
|
||||
capabilities: [
|
||||
'runtime.status.compat.v1',
|
||||
'runtime.environments.v1',
|
||||
'mobile.tasks.v1',
|
||||
'workspace-run-context.v1'
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
|
||||
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith('status.get', undefined, {
|
||||
timeoutMs: 30_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects historical hosts without explicit removal capability', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(status({ appVersion: '1.4.90' }))
|
||||
|
||||
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
|
||||
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts capability-proven current development hosts', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
status({ appVersion: '0.0.0-dev', capabilities: ['terminal.attribution-removed.v1'] })
|
||||
)
|
||||
|
||||
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).resolves.toEqual(
|
||||
{ runtimeId: 'runtime' }
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when host status cannot be verified', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: false,
|
||||
error: { code: 'offline', message: 'Host is offline' },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
|
||||
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
|
||||
'Host is offline'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed with an actionable error for malformed host status', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(
|
||||
status({
|
||||
appVersion: 1.49,
|
||||
capabilities: 'terminal.attribution-removed.v1'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
|
||||
'Update the host and try again'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable,
|
||||
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export const MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS = 30_000
|
||||
export const MOBILE_TERMINAL_CREATE_RPC_OPTIONS = {
|
||||
timeoutMs: MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS,
|
||||
budgetSpansConnect: true
|
||||
} as const
|
||||
|
||||
export type MobileTerminalAttributionAuthority = Readonly<{ runtimeId: string }>
|
||||
|
||||
export async function assertMobileTerminalAttributionDisableSupported(
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
): Promise<MobileTerminalAttributionAuthority> {
|
||||
const response = await client.sendRequest(
|
||||
'status.get',
|
||||
undefined,
|
||||
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to verify the workspace host version.')
|
||||
}
|
||||
if (!hostSupportsSessionTabTerminalCreateAttributionDisable(response.result)) {
|
||||
throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
const runtimeId = Reflect.get(response.result as object, 'runtimeId')
|
||||
if (typeof runtimeId !== 'string' || !runtimeId) {
|
||||
throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
return Object.freeze({ runtimeId })
|
||||
}
|
||||
@@ -12,10 +12,6 @@ function failure(message: string): RpcResponse {
|
||||
|
||||
const createdTerminal = success({ tab: { type: 'terminal', id: 't1', terminal: 'term-1' } })
|
||||
const sendAccepted = success({ send: { accepted: true } })
|
||||
const safeHost = success({
|
||||
runtimeId: 'r',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
})
|
||||
|
||||
function clientReturning(...responses: RpcResponse[]) {
|
||||
const sendRequest = vi.fn(async () => responses[sendRequest.mock.calls.length - 1])
|
||||
@@ -24,27 +20,16 @@ function clientReturning(...responses: RpcResponse[]) {
|
||||
|
||||
describe('createTerminalAndSendPrompt', () => {
|
||||
it('creates a terminal then sends the prompt with enter', async () => {
|
||||
const client = clientReturning(safeHost, createdTerminal, sendAccepted)
|
||||
const client = clientReturning(createdTerminal, sendAccepted)
|
||||
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
|
||||
timeoutMs: 30_000,
|
||||
budgetSpansConnect: true
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
|
||||
worktree: 'id:wt-1',
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'r' }
|
||||
)
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'terminal.send', {
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: 'do the thing',
|
||||
enter: true
|
||||
@@ -52,41 +37,28 @@ describe('createTerminalAndSendPrompt', () => {
|
||||
})
|
||||
|
||||
it('throws and skips terminal.send when createTerminal fails', async () => {
|
||||
const client = clientReturning(safeHost, failure('boom'))
|
||||
const client = clientReturning(failure('boom'))
|
||||
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when the created-terminal response is malformed', async () => {
|
||||
const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } }))
|
||||
const client = clientReturning(success({ tab: { type: 'terminal' } }))
|
||||
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
|
||||
'Created terminal response was invalid'
|
||||
)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when terminal.send returns a failure', async () => {
|
||||
const client = clientReturning(safeHost, createdTerminal, failure('send failed'))
|
||||
const client = clientReturning(createdTerminal, failure('send failed'))
|
||||
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('send failed')
|
||||
})
|
||||
|
||||
it('throws when terminal input is locked', async () => {
|
||||
const client = clientReturning(
|
||||
safeHost,
|
||||
createdTerminal,
|
||||
success({ send: { accepted: false } })
|
||||
)
|
||||
const client = clientReturning(createdTerminal, success({ send: { accepted: false } }))
|
||||
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
|
||||
'Terminal input is locked'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses an old host before terminal creation', async () => {
|
||||
const client = clientReturning(success({ appVersion: '1.4.89' }))
|
||||
|
||||
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
|
||||
'Update the host and try again'
|
||||
)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,6 @@ import {
|
||||
readMobileReviewCreatedTerminal,
|
||||
readMobileReviewTerminalSendAccepted
|
||||
} from './mobile-diff-review-rpc'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
assertMobileTerminalAttributionDisableSupported,
|
||||
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
|
||||
} from './mobile-terminal-attribution-compat'
|
||||
|
||||
// Pure launch path for the PR triage actions ("Fix checks with AI" / "Resolve
|
||||
// conflicts with AI"). Reuses the same two RPCs the diff-review send flow uses —
|
||||
@@ -23,19 +15,12 @@ export async function createTerminalAndSendPrompt(
|
||||
worktreeId: string,
|
||||
prompt: string
|
||||
): Promise<void> {
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const created = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
|
||||
)
|
||||
const created = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!created.ok) {
|
||||
throw new Error(created.error?.message || 'Failed to create terminal')
|
||||
}
|
||||
|
||||
@@ -163,74 +163,6 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
expect(setSendSheet).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('creates an unattributed terminal before sending review notes', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
},
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'create',
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'terminal-1' } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
.mockResolvedValueOnce(sendResponse(true))
|
||||
await mount({ sendRequest } as unknown as RpcClient)
|
||||
|
||||
await act(async () => {
|
||||
await actions?.createTerminalAndSend([COMMENT])
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
|
||||
timeoutMs: 30_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'runtime' }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'terminal.send',
|
||||
expect.objectContaining({ terminal: 'terminal-1', enter: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to create review terminals on hosts that strip the bypass', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { appVersion: '1.4.89' },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await mount({ sendRequest } as unknown as RpcClient)
|
||||
|
||||
let error: unknown
|
||||
await act(async () => {
|
||||
error = await actions?.createTerminalAndSend([COMMENT]).catch((err) => err)
|
||||
})
|
||||
|
||||
expect((error as Error).message).toContain('Update the host and try again')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('terminal.send', expect.anything())
|
||||
})
|
||||
|
||||
it('only heals the terminal that was marked', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(sendResponse(true))
|
||||
await mount({ sendRequest } as unknown as RpcClient)
|
||||
|
||||
@@ -13,14 +13,6 @@ import {
|
||||
} from './mobile-diff-review-rpc'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
assertMobileTerminalAttributionDisableSupported,
|
||||
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
|
||||
} from './mobile-terminal-attribution-compat'
|
||||
|
||||
type SendActionsInput = {
|
||||
client: RpcClient | null
|
||||
@@ -112,19 +104,12 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
if (!client || connState !== 'connected') {
|
||||
throw new Error('Waiting for desktop...')
|
||||
}
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const response = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
|
||||
)
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Failed to create terminal')
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ type ConnectWaiter = {
|
||||
|
||||
export type SendRequestOptions = {
|
||||
timeoutMs?: number
|
||||
expectedRuntimeId?: string
|
||||
/** Spend `timeoutMs` across connect-wait AND the request instead of giving each
|
||||
* phase its own. Interactive chat writes need it: they run as sequential loops
|
||||
* under one shared budget, so a per-phase clock lets the composer sit `sending`
|
||||
@@ -1014,15 +1013,7 @@ export function connect(
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
!sendEncrypted({
|
||||
id,
|
||||
deviceToken,
|
||||
method,
|
||||
params,
|
||||
...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {})
|
||||
})
|
||||
) {
|
||||
if (!sendEncrypted({ id, deviceToken, method, params })) {
|
||||
pending.delete(id)
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Connection interrupted'))
|
||||
|
||||
@@ -18,7 +18,6 @@ export type RpcRequest = {
|
||||
deviceToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
|
||||
export type RpcSuccess = {
|
||||
|
||||
@@ -61,67 +61,3 @@ describe('terminal close CLI', () => {
|
||||
expect(help).toContain('durable persistence')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal split CLI', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends the old-host attribution bypass through the legacy-compatible env field', async () => {
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'status.get'
|
||||
? {
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
}
|
||||
: { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } }
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal split']({
|
||||
flags: new Map([
|
||||
['terminal', 'term-1'],
|
||||
['direction', 'horizontal']
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'status.get')
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal.split',
|
||||
{
|
||||
terminal: 'term-1',
|
||||
direction: 'horizontal',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
|
||||
})
|
||||
|
||||
await expect(
|
||||
TERMINAL_HANDLERS['terminal split']({
|
||||
flags: new Map([['terminal', 'term-1']]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'runtime_update_required',
|
||||
message: expect.stringContaining('Update the host and try again')
|
||||
})
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalShow,
|
||||
RuntimeTerminalSplit,
|
||||
RuntimeTerminalWait,
|
||||
RuntimeStatus
|
||||
RuntimeTerminalWait
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
|
||||
@@ -38,14 +37,6 @@ import {
|
||||
getRequiredWorktreeSelector,
|
||||
getTerminalHandle
|
||||
} from '../selectors'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
hostSupportsTerminalCreateAttributionDisable,
|
||||
hostSupportsTerminalSplitAttributionDisable,
|
||||
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../shared/legacy-terminal-attribution-env'
|
||||
|
||||
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
|
||||
// timeout. Even without an explicit server timeout, the client must allow
|
||||
@@ -146,30 +137,17 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
const useRendererBackedInteractiveTerminal =
|
||||
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
|
||||
const focus = flags.get('focus') === true
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (!hostSupportsTerminalCreateAttributionDisable(status.result)) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_update_required',
|
||||
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
// Why: interactive local agent TUIs need the renderer-backed terminal
|
||||
// path for browser-side features, but CLI creates must stay backgrounded
|
||||
// unless the caller explicitly asks for focus.
|
||||
focus,
|
||||
...(focus ? { presentation: 'focused' } : {}),
|
||||
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
|
||||
},
|
||||
{ expectedRuntimeId: status.result.runtimeId }
|
||||
)
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command,
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
// Why: interactive local agent TUIs need the renderer-backed terminal
|
||||
// path for browser-side features, but CLI creates must stay backgrounded
|
||||
// unless the caller explicitly asks for focus.
|
||||
focus,
|
||||
...(focus ? { presentation: 'focused' } : {}),
|
||||
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
|
||||
})
|
||||
printResult(result, json, formatTerminalCreate)
|
||||
},
|
||||
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
|
||||
@@ -190,24 +168,11 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
) {
|
||||
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
|
||||
}
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (!hostSupportsTerminalSplitAttributionDisable(status.result)) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_update_required',
|
||||
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ split: RuntimeTerminalSplit }>(
|
||||
'terminal.split',
|
||||
{
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
direction: directionFlag,
|
||||
command: getOptionalStringFlag(flags, 'command'),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined)
|
||||
},
|
||||
{ expectedRuntimeId: status.result.runtimeId }
|
||||
)
|
||||
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
direction: directionFlag,
|
||||
command: getOptionalStringFlag(flags, 'command')
|
||||
})
|
||||
printResult(result, json, formatTerminalSplit)
|
||||
}
|
||||
}
|
||||
|
||||
+82
-202
@@ -3112,10 +3112,6 @@ describe('orca cli worktree awareness', () => {
|
||||
it('passes explicit focus through terminal.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3140,19 +3136,13 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'RUNNER',
|
||||
focus: true,
|
||||
presentation: 'focused'
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: undefined,
|
||||
title: 'RUNNER',
|
||||
focus: true,
|
||||
presentation: 'focused'
|
||||
})
|
||||
})
|
||||
|
||||
it('prints terminal.read fallback screen lines in json mode', async () => {
|
||||
@@ -3194,10 +3184,6 @@ describe('orca cli worktree awareness', () => {
|
||||
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3223,29 +3209,19 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3272,30 +3248,20 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: true,
|
||||
presentation: 'focused',
|
||||
rendererBacked: true,
|
||||
activate: true
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: true,
|
||||
presentation: 'focused',
|
||||
rendererBacked: true,
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3321,27 +3287,17 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex exec summarize',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3367,27 +3323,17 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex review commands after long options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3413,27 +3359,17 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex review',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
title: 'Codex review',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex help commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3459,27 +3395,17 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --help',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex help',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --help',
|
||||
title: 'Codex help',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3505,29 +3431,19 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 "fix the flaky test"',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex prompt',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 "fix the flaky test"',
|
||||
title: 'Codex prompt',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3553,29 +3469,19 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Claude',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude',
|
||||
title: 'Claude',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Claude print commands on the background terminal path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3601,18 +3507,12 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude -p "summarize"',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Claude print',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude -p "summarize"',
|
||||
title: 'Claude print',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
|
||||
@@ -3919,10 +3819,6 @@ describe('orca cli worktree awareness', () => {
|
||||
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3946,18 +3842,12 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: undefined,
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: undefined,
|
||||
title: undefined,
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('collects and formats memory diagnostics', async () => {
|
||||
@@ -4065,10 +3955,6 @@ describe('orca cli worktree awareness', () => {
|
||||
it('does not force remote Codex terminal creates through a local renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -4096,18 +3982,12 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resolve implicit remote browser targets from client cwd', async () => {
|
||||
|
||||
@@ -87,7 +87,6 @@ export class RuntimeClient {
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
expectedRuntimeId: options?.expectedRuntimeId,
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
@@ -50,48 +50,6 @@ describe('runtime transport timeout validation', () => {
|
||||
// Why: these tests create Unix domain socket servers in temp directories.
|
||||
// Windows does not support Unix domain sockets in the same way.
|
||||
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
|
||||
it('forwards the expected runtime fence in local requests', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | null = null
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: receivedRequest.id,
|
||||
ok: true,
|
||||
result: { terminal: { handle: 'terminal-1' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
|
||||
await sendRequest(
|
||||
{
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 123,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
},
|
||||
'terminal.create',
|
||||
{ worktree: 'id:worktree-1' },
|
||||
1_000,
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
|
||||
expect(receivedRequest).toMatchObject({
|
||||
method: 'terminal.create',
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
|
||||
@@ -186,7 +186,6 @@ export async function sendRequest<TResult>(
|
||||
authToken: metadata.authToken,
|
||||
method,
|
||||
params,
|
||||
expectedRuntimeId: envelope?.expectedRuntimeId,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
|
||||
@@ -183,7 +183,6 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
@@ -198,8 +197,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision,
|
||||
args.expectedRuntimeId ? { expectedRuntimeId: args.expectedRuntimeId } : undefined
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
} catch (error) {
|
||||
const failure = runtimeEnvironmentCallFailure(environment, args.method, error)
|
||||
|
||||
@@ -77,15 +77,13 @@ export function subscribeRemoteRuntimeSharedControlRequest<TResult>(
|
||||
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
onError: (error: { code: string; message: string }) => void
|
||||
onClose?: () => void
|
||||
},
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
}
|
||||
): Promise<RemoteRuntimeSharedSubscription> {
|
||||
return getSharedControlConnection(environmentId, pairing).subscribe(
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
callbacks,
|
||||
envelope?.expectedRuntimeId
|
||||
callbacks
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -193,14 +193,11 @@ export async function subscribeRuntimeEnvironment(
|
||||
| { type: 'close' }
|
||||
) => void
|
||||
onClose: () => void
|
||||
},
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
): Promise<RemoteRuntimeSubscription> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
const pairing = getPreferredPairingOffer(environment)
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
const envelope: RuntimeOrchestrationEnvelope | undefined =
|
||||
expectedRuntimeId === undefined ? undefined : { expectedRuntimeId }
|
||||
let markedUsed = false
|
||||
const markUsedOnce = (runtimeId: string): void => {
|
||||
if (markedUsed) {
|
||||
@@ -243,8 +240,7 @@ export async function subscribeRuntimeEnvironment(
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
callbacksWithMarkUsed,
|
||||
envelope
|
||||
callbacksWithMarkUsed
|
||||
)
|
||||
}
|
||||
return await subscribeRemoteRuntimeRequest(
|
||||
@@ -252,9 +248,7 @@ export async function subscribeRuntimeEnvironment(
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
callbacksWithMarkUsed,
|
||||
undefined,
|
||||
envelope
|
||||
callbacksWithMarkUsed
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1094,13 +1094,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
await subscribe(
|
||||
@@ -1113,12 +1107,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'browser.screencast',
|
||||
params: { pageId: 'page-1' },
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
}
|
||||
{ selector: 'desk', method: 'browser.screencast', params: { pageId: 'page-1' } }
|
||||
)
|
||||
await subscribe(
|
||||
{
|
||||
@@ -1138,18 +1127,14 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'browser.screencast',
|
||||
{ pageId: 'page-1' },
|
||||
15_000,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'terminal.multiplex',
|
||||
{ client: { id: 'client-1' } },
|
||||
15_000,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1178,13 +1163,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
await expect(
|
||||
@@ -1198,11 +1177,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'session.tabs.subscribeAll',
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
}
|
||||
{ selector: 'desk', method: 'session.tabs.subscribeAll' }
|
||||
)
|
||||
).resolves.toMatchObject({ requestId: 'tabs-shared' })
|
||||
|
||||
@@ -1212,8 +1187,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'session.tabs.subscribeAll',
|
||||
undefined,
|
||||
15_000,
|
||||
expect.any(Object),
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1341,9 +1315,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'session.tabs.subscribeAll',
|
||||
undefined,
|
||||
15_000,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1687,9 +1659,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'terminal.subscribe',
|
||||
{ terminal: 't1' },
|
||||
25,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
|
||||
|
||||
@@ -90,7 +90,6 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
timeoutMs?: number
|
||||
subscriptionId?: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
): Promise<{ subscriptionId: string; requestId: string }> => {
|
||||
const subscriptionId =
|
||||
@@ -181,8 +180,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
retained?.removeDestroyedListener()
|
||||
remoteRuntimeSubscriptions.delete(subscriptionId)
|
||||
}
|
||||
},
|
||||
args.expectedRuntimeId
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
removeDestroyedListener()
|
||||
|
||||
@@ -66,12 +66,17 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(statSync(path).mode & 0o111).not.toBe(0)
|
||||
}
|
||||
}
|
||||
expect(readFileSync(join(legacyRoot, 'VERSION'), 'utf8')).toBe('7\n')
|
||||
// Why: must not equal the retired shim's own '7', or a rolled-back build treats its wrappers
|
||||
// as current and never rewrites them.
|
||||
const version = readFileSync(join(legacyRoot, 'VERSION'), 'utf8')
|
||||
expect(version).toBe('7-neutralized\n')
|
||||
expect(version.trim()).not.toBe('7')
|
||||
})
|
||||
|
||||
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
@@ -79,6 +84,10 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(cmd).toContain(
|
||||
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
|
||||
)
|
||||
// Why: a captured path that no longer exists must be cleared, or the where.exe fallback below
|
||||
// is skipped and the wrapper execs a missing binary.
|
||||
expect(cmd).toContain('if defined orca_real if not exist "%orca_real%" set "orca_real="')
|
||||
expect(cmd.indexOf('if not exist "%orca_real%"')).toBeLessThan(cmd.indexOf('where.exe git.exe'))
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
expect(powershell).toContain('$realCommand = $null')
|
||||
@@ -90,6 +99,7 @@ describe('legacy terminal shim neutralization', () => {
|
||||
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
@@ -111,11 +121,13 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
})
|
||||
|
||||
it('does not throw when the legacy directory is absent', () => {
|
||||
it('leaves an install that never ran the shim untouched', () => {
|
||||
// Why: a clean install has no resolved wrapper paths to keep alive, so writing tombstones
|
||||
// there would recreate the very directory the removal deleted.
|
||||
const userData = makeUserDataDir()
|
||||
|
||||
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true)
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
|
||||
@@ -145,6 +157,48 @@ describe('legacy terminal shim neutralization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('warns and stops retrying once the ladder is exhausted', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
// Why: keep every attempt failing so the ladder runs to exhaustion.
|
||||
chmodSync(posixDir, 0o500)
|
||||
try {
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
// 1s + 5s + 15s + 30s covers every configured delay, plus slack for a fifth that must not fire.
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(readFileSync(join(posixDir, 'git'), 'utf8')).toBe('legacy attribution wrapper')
|
||||
const messages = warn.mock.calls.map((call) => String(call[0]))
|
||||
expect(messages.filter((message) => message.includes('neutralization attempt'))).toHaveLength(
|
||||
5
|
||||
)
|
||||
// Why: pin the ordinals too — the count alone would not catch an off-by-one.
|
||||
expect(messages.some((message) => message.includes('neutralization attempt 1 failed'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(messages.some((message) => message.includes('neutralization attempt 5 failed'))).toBe(
|
||||
true
|
||||
)
|
||||
// Why: the give-up count must agree with the last per-attempt line, not the retry counter.
|
||||
expect(
|
||||
messages.some((message) => message.includes('gave up neutralizing after 5 attempts'))
|
||||
).toBe(true)
|
||||
|
||||
// Exhausted means quiet: no further timers, so no further warnings.
|
||||
warn.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
chmodSync(posixDir, 0o700)
|
||||
warn.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => {
|
||||
const userData = makeUserDataDir()
|
||||
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY,
|
||||
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
|
||||
} from '../../shared/legacy-terminal-attribution-env'
|
||||
import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone'
|
||||
|
||||
const LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY = 'ORCA_ENABLE_GIT_ATTRIBUTION'
|
||||
const LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY = 'ORCA_ATTRIBUTION_BYPASS'
|
||||
|
||||
const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution'
|
||||
const LEGACY_SHIM_VERSION = '7'
|
||||
// Why: must differ from the retired shim's own '7'. A rolled-back build compares this marker and
|
||||
// skips rewriting its wrappers when it matches, which would leave our tombstones in place while
|
||||
// its attribution toggle claimed to be on.
|
||||
const LEGACY_SHIM_VERSION = '7-neutralized'
|
||||
const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000]
|
||||
export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [
|
||||
'ORCA_ENABLE_GIT_ATTRIBUTION',
|
||||
@@ -40,7 +42,9 @@ set "ORCA_ATTRIBUTION_BYPASS="
|
||||
set "ORCA_REAL_GIT="
|
||||
set "ORCA_REAL_GH="
|
||||
if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="
|
||||
if defined orca_real if exist "%orca_real%" goto run
|
||||
rem Why: clear a captured path that no longer exists, or the where.exe fallback below is skipped.
|
||||
if defined orca_real if not exist "%orca_real%" set "orca_real="
|
||||
if defined orca_real goto run
|
||||
for /f "delims=" %%G in ('where.exe __ORCA_COMMAND__.exe 2^>nul') do if not defined orca_real set "orca_real=%%G"
|
||||
if not defined orca_real (
|
||||
echo Orca compatibility wrapper could not locate __ORCA_COMMAND__ on PATH. 1>&2
|
||||
@@ -101,23 +105,40 @@ export function neutralizeLegacyTerminalShimDir(userDataPath: string): void {
|
||||
if (neutralized) {
|
||||
return
|
||||
}
|
||||
const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR)
|
||||
// Why: only installs that actually ran the shim have resolved wrapper paths worth keeping
|
||||
// alive. Writing them anywhere else would recreate the directory the removal deleted.
|
||||
if (!existsSync(rootDir)) {
|
||||
neutralized = true
|
||||
clearNeutralizationRetry()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR)
|
||||
writeNeutralWrappers(rootDir)
|
||||
writeFileAtomically(join(rootDir, 'VERSION'), `${LEGACY_SHIM_VERSION}\n`, 0o644)
|
||||
neutralized = true
|
||||
clearNeutralizationRetry()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why: Windows can keep a running .cmd open briefly after startup.
|
||||
console.warn(
|
||||
`[legacy-terminal-shim] neutralization attempt ${neutralizationRetryAttempt + 1} failed:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
scheduleNeutralizationRetry(userDataPath)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNeutralizationRetry(userDataPath: string): void {
|
||||
if (
|
||||
neutralizationRetryTimer ||
|
||||
neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
if (neutralizationRetryTimer) {
|
||||
return
|
||||
}
|
||||
if (neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length) {
|
||||
// Why: retries stop here for the process lifetime; without this the give-up is invisible and a
|
||||
// host left holding live wrappers is undiagnosable.
|
||||
// Why: +1 counts the initial attempt, so this agrees with the per-attempt line above.
|
||||
console.warn(
|
||||
`[legacy-terminal-shim] gave up neutralizing after ${neutralizationRetryAttempt + 1} attempts; legacy git/gh wrappers may remain until the next launch`
|
||||
)
|
||||
return
|
||||
}
|
||||
const delayMs = NEUTRALIZATION_RETRY_DELAYS_MS[neutralizationRetryAttempt]
|
||||
@@ -172,7 +193,11 @@ function writeFileAtomically(filePath: string, contents: string, mode: number):
|
||||
chmodSync(temporaryPath, mode)
|
||||
renameSync(temporaryPath, filePath)
|
||||
} finally {
|
||||
rmSync(temporaryPath, { force: true, recursive: true })
|
||||
try {
|
||||
rmSync(temporaryPath, { force: true, recursive: true })
|
||||
} catch {
|
||||
// Why: a locked temp file must not replace the in-flight error with a cleanup error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ export type RpcRequest = {
|
||||
authToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
expectedRuntimeId?: string
|
||||
orchestrationCapability?: string
|
||||
orchestrationContractVersion?: number
|
||||
orchestrationRequestId?: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { defineMethod, InvalidArgumentError, type RpcRequest } from './core'
|
||||
@@ -41,26 +41,6 @@ const METHODS = [
|
||||
]
|
||||
|
||||
describe('RpcDispatcher computer-use validation errors', () => {
|
||||
it('rejects a replacement runtime before invoking a one-shot handler', async () => {
|
||||
const handler = vi.fn(() => ({ ok: true }))
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: makeRuntime(),
|
||||
methods: [defineMethod({ name: 'terminal.create', params: z.object({}), handler })]
|
||||
})
|
||||
|
||||
const response = await dispatcher.dispatch({
|
||||
...makeRequest('terminal.create', {}),
|
||||
expectedRuntimeId: 'previous-runtime'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_replaced' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds recovery steps to one-shot computer schema failures', async () => {
|
||||
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
|
||||
|
||||
|
||||
@@ -43,14 +43,6 @@ export class RpcDispatcher {
|
||||
|
||||
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
|
||||
const meta = this.meta()
|
||||
if (request.expectedRuntimeId && request.expectedRuntimeId !== meta.runtimeId) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'runtime_replaced',
|
||||
'The runtime changed before the request could be applied. Refresh and try again.'
|
||||
)
|
||||
}
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
return errorResponse(
|
||||
@@ -147,19 +139,6 @@ export class RpcDispatcher {
|
||||
options?: RpcDispatchStreamingOptions
|
||||
): Promise<void> {
|
||||
const meta = this.meta()
|
||||
if (request.expectedRuntimeId && request.expectedRuntimeId !== meta.runtimeId) {
|
||||
reply(
|
||||
JSON.stringify(
|
||||
errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'runtime_replaced',
|
||||
'The runtime changed before the request could be applied. Refresh and try again.'
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
reply(
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { TERMINAL_METHODS } from './terminal'
|
||||
|
||||
describe('terminal split environment forwarding', () => {
|
||||
it('forwards requested environment deletions to the runtime owner', async () => {
|
||||
const splitTerminal = vi.fn().mockResolvedValue({ handle: 'term-split' })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
splitTerminal
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const request: RpcRequest = {
|
||||
id: 'split-1',
|
||||
authToken: 'token',
|
||||
method: 'terminal.split',
|
||||
params: {
|
||||
terminal: 'term-1',
|
||||
direction: 'horizontal',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
|
||||
}
|
||||
}
|
||||
|
||||
await dispatcher.dispatch(request)
|
||||
|
||||
expect(splitTerminal).toHaveBeenCalledWith('term-1', {
|
||||
direction: 'horizontal',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
telemetrySource: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -985,7 +985,6 @@ const TerminalSplit = TerminalHandle.extend({
|
||||
.optional(),
|
||||
command: OptionalString,
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
|
||||
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
|
||||
})
|
||||
|
||||
@@ -1473,7 +1472,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
direction: params.direction,
|
||||
command: params.command,
|
||||
env: params.env,
|
||||
envToDelete: params.envToDelete,
|
||||
telemetrySource: params.telemetrySource
|
||||
})
|
||||
})
|
||||
|
||||
@@ -231,28 +231,6 @@ describe('RpcDispatcher streaming', () => {
|
||||
expect(response.streaming).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a replacement runtime before streaming dispatch invokes a mutation', async () => {
|
||||
const messages: string[] = []
|
||||
const handler = vi.fn(() => ({ terminal: { handle: 'terminal-1' } }))
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: [defineMethod({ name: 'terminal.create', params: z.object({}), handler })]
|
||||
})
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
{ ...makeRequest('terminal.create', {}), expectedRuntimeId: 'previous-runtime' },
|
||||
(message) => messages.push(message)
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_replaced' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error for unknown method via dispatchStreaming', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({
|
||||
|
||||
@@ -3415,7 +3415,6 @@ export type PreloadApi = {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}) => Promise<RuntimeRpcResponse<unknown>>
|
||||
subscribe: (
|
||||
args: {
|
||||
@@ -3424,7 +3423,6 @@ export type PreloadApi = {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
|
||||
@@ -4429,7 +4429,6 @@ const api = {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}): Promise<RuntimeRpcResponse<unknown>> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:call', args),
|
||||
subscribe: async (
|
||||
@@ -4439,7 +4438,6 @@ const api = {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
|
||||
@@ -68,12 +68,7 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => {
|
||||
|
||||
const cleanupPromise = subscribeRuntimeEnvironmentFromPreload(
|
||||
ipc,
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
expectedEnvironmentPairingRevision: 7,
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
},
|
||||
{ selector: 'desk', method: 'terminal.subscribe' },
|
||||
{ onResponse, onBinary },
|
||||
() => 'sub-1'
|
||||
)
|
||||
@@ -85,8 +80,6 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => {
|
||||
expect(ipc.invoke).toHaveBeenCalledWith('runtimeEnvironments:subscribe', {
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
expectedEnvironmentPairingRevision: 7,
|
||||
expectedRuntimeId: 'runtime-1',
|
||||
subscriptionId: 'sub-1'
|
||||
})
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ type RuntimeEnvironmentSubscribeArgs = {
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
|
||||
type RuntimeEnvironmentSubscriptionCallbacks = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
encodeTerminalStreamText
|
||||
} from '../../../../shared/terminal-stream-protocol'
|
||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
||||
import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
@@ -2536,10 +2535,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: [
|
||||
'agent-session.host-authority.v1',
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
|
||||
]
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
@@ -2638,14 +2634,12 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
worktree: 'id:repo1::/remote/wt',
|
||||
clientMutationId: expect.any(String),
|
||||
command: 'claude',
|
||||
env: { ORCA_TAB_ID: 'tab-1', ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
env: { ORCA_TAB_ID: 'tab-1' },
|
||||
tabId: 'tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
focus: false,
|
||||
presentation: 'background'
|
||||
},
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeSubscribe).toHaveBeenCalledWith(
|
||||
@@ -2697,34 +2691,20 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
})
|
||||
|
||||
it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => {
|
||||
runtimeCall.mockImplementation(async (args: { method?: string }) =>
|
||||
args.method === 'status.get'
|
||||
? {
|
||||
id: 'rpc-status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-remote',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY]
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
: {
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'term-original',
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
title: 'Original',
|
||||
surface: 'background',
|
||||
isReattach: true
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
)
|
||||
runtimeCall.mockResolvedValue({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'term-original',
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
title: 'Original',
|
||||
surface: 'background',
|
||||
isReattach: true
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const onPtySpawn = vi.fn()
|
||||
const onReattachDetermined = vi.fn()
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
@@ -2852,7 +2832,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
},
|
||||
presentation: 'background'
|
||||
},
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
@@ -2878,13 +2857,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
id: 'rpc-status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-remote',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: [
|
||||
'agent-session.host-authority.v1',
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
|
||||
]
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -2930,7 +2905,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
},
|
||||
presentation: 'background'
|
||||
},
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
|
||||
describe('createRemoteRuntimePtyTransport', () => {
|
||||
const runtimeCall = vi.fn()
|
||||
@@ -118,19 +113,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function currentRuntimeStatus(capabilities: string[] = []): unknown {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, ...capabilities]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitOutput(streamId: number, data: string): void {
|
||||
subscriptionCallbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
@@ -205,18 +187,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
subscriptionSendBinary.mockReset()
|
||||
refreshSessionTabsSnapshot.mockClear()
|
||||
runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => {
|
||||
if (request.method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request.method === 'session.tabs.activate') {
|
||||
const params = request.params as { tabId: string; leafId?: string }
|
||||
const resolvedLeafId = params.leafId ?? 'pane:1'
|
||||
@@ -571,7 +541,10 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
let createCalls = 0
|
||||
runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => {
|
||||
if (args.method === 'status.get') {
|
||||
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
createCalls += 1
|
||||
@@ -619,14 +592,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
let createCalls = 0
|
||||
let statusCalls = 0
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) => {
|
||||
if (args.method === 'status.get') {
|
||||
statusCalls += 1
|
||||
if (statusCalls > 1) {
|
||||
vi.setSystemTime(startedAt + 59_000)
|
||||
vi.setSystemTime(startedAt + 59_000)
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
|
||||
}
|
||||
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
createCalls += 1
|
||||
@@ -660,7 +632,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
it('does not retry an unknown create outcome against an older runtime', async () => {
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) => {
|
||||
if (args.method === 'status.get') {
|
||||
return currentRuntimeStatus()
|
||||
return { ok: true, result: { capabilities: [] } }
|
||||
}
|
||||
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
|
||||
code: 'runtime_timeout'
|
||||
@@ -681,16 +653,12 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
})
|
||||
|
||||
it('surfaces an authoritative capability-probe failure after an unknown create outcome', async () => {
|
||||
let statusCalls = 0
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) => {
|
||||
if (args.method === 'status.get' && ++statusCalls > 1) {
|
||||
if (args.method === 'status.get') {
|
||||
throw Object.assign(new Error('Remote runtime pairing credentials expired.'), {
|
||||
code: 'unauthorized'
|
||||
})
|
||||
}
|
||||
if (args.method === 'status.get') {
|
||||
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
|
||||
}
|
||||
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
|
||||
code: 'runtime_timeout'
|
||||
})
|
||||
@@ -726,7 +694,10 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
}, args.timeoutMs)
|
||||
})
|
||||
}
|
||||
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (args.method === 'terminal.create' && reachable) {
|
||||
return { ok: true, result: { terminal: { handle: 'terminal-recovered' } } }
|
||||
@@ -792,10 +763,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,65 +813,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
.filter((args) => args.method === 'terminal.createAgentSession')
|
||||
expect(allCreates.every((args) => args.params?.clientOperationId === operationId)).toBe(true)
|
||||
expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false)
|
||||
expect(
|
||||
runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get').length
|
||||
).toBeGreaterThan(1)
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses structured replay after an in-place host downgrade', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let statusCalls = 0
|
||||
const onError = vi.fn()
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) => {
|
||||
if (args.method === 'status.get') {
|
||||
statusCalls += 1
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities:
|
||||
statusCalls === 1
|
||||
? ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
if (args.method === 'terminal.createAgentSession') {
|
||||
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
|
||||
code: 'runtime_timeout'
|
||||
})
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
})
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
|
||||
const connect = transport.connect({ url: '', callbacks: { onError } })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await connect
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no fallback launch was attempted')
|
||||
)
|
||||
expect(
|
||||
runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.createAgentSession')
|
||||
).toHaveLength(1)
|
||||
expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get')).toHaveLength(
|
||||
2
|
||||
1
|
||||
)
|
||||
expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false)
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -3941,9 +3855,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
it('closes a remote terminal created after the pane was destroyed', async () => {
|
||||
let resolveCreate: (value: unknown) => void = () => {}
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
@@ -3959,11 +3870,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
})
|
||||
|
||||
const connect = transport.connect({ url: '', callbacks: {} })
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.create' })
|
||||
)
|
||||
)
|
||||
transport.destroy?.()
|
||||
resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } })
|
||||
await connect
|
||||
@@ -3972,17 +3878,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
selector: 'env-1',
|
||||
method: 'terminal.close',
|
||||
params: { terminal: 'terminal-late' },
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: undefined
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot let a stale create completion replace a newer attached terminal', async () => {
|
||||
let resolveCreate: (value: unknown) => void = () => {}
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
@@ -3998,11 +3900,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
})
|
||||
|
||||
const connect = transport.connect({ url: '', callbacks: {} })
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.create' })
|
||||
)
|
||||
)
|
||||
transport.attach({ existingPtyId: 'remote:env-2@@terminal-attached', callbacks: {} })
|
||||
resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } })
|
||||
await connect
|
||||
@@ -4013,8 +3910,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
selector: 'env-1',
|
||||
method: 'terminal.close',
|
||||
params: { terminal: 'terminal-late' },
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: undefined
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
transport.destroy?.()
|
||||
})
|
||||
@@ -4028,7 +3924,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4078,7 +3974,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4193,7 +4089,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
method: 'terminal.create',
|
||||
params: expect.objectContaining({
|
||||
command: "codex 'linked issue context'",
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
|
||||
})
|
||||
@@ -4209,11 +4105,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: [
|
||||
'agent-session.host-authority.v1',
|
||||
'agent-session.omp-resume-path.v1',
|
||||
'terminal.attribution-removed.v1'
|
||||
]
|
||||
capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1']
|
||||
}
|
||||
}
|
||||
: { ok: true, result: { terminal: { handle: 'terminal-1' } } }
|
||||
@@ -4277,7 +4169,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
: {
|
||||
@@ -4327,10 +4219,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY]
|
||||
capabilities: []
|
||||
}
|
||||
}
|
||||
: { ok: true, result: { terminal: { handle: 'terminal-legacy' } } }
|
||||
@@ -4366,12 +4257,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
worktree: 'id:wt-1',
|
||||
clientMutationId: expect.any(String),
|
||||
command: "codex '--model' 'gpt-5' 'resume' 'session-1'",
|
||||
env: {
|
||||
CODEX_PROFILE: 'captured',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token',
|
||||
ORCA_ATTRIBUTION_BYPASS: '1'
|
||||
},
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' },
|
||||
launchConfig: {
|
||||
agentArgs: '--model gpt-5',
|
||||
agentEnv: { CODEX_PROFILE: 'captured' }
|
||||
@@ -4383,9 +4269,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
focus: false,
|
||||
presentation: 'background'
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.createAgentSession' })
|
||||
@@ -5802,9 +5686,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
it('returns runtime acceptance for acknowledged terminal input', async () => {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -5844,9 +5725,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -5892,9 +5770,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
it('returns false when acknowledged terminal input is rejected by the runtime', async () => {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -5920,9 +5795,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
it('splits large acknowledged remote input before terminal.send RPCs', async () => {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -5962,9 +5834,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -6014,9 +5883,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
const firstChunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
|
||||
const rejectedChunk = `tail${'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES - 4)}`
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
@@ -6055,9 +5921,6 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
it('rejects oversized acknowledged remote input before runtime RPCs', async () => {
|
||||
runtimeCall.mockImplementation((args) => {
|
||||
if (args.method === 'status.get') {
|
||||
return Promise.resolve(currentRuntimeStatus())
|
||||
}
|
||||
if (args.method === 'terminal.create') {
|
||||
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
}
|
||||
|
||||
@@ -20,11 +20,8 @@ import type {
|
||||
RuntimeTerminalSend
|
||||
} from '../../../../shared/runtime-types'
|
||||
import {
|
||||
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
@@ -37,11 +34,7 @@ import type {
|
||||
PtyTransportRecoveryState
|
||||
} from './pty-transport-types'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
import {
|
||||
RuntimeRpcCallError,
|
||||
unwrapRuntimeRpcResult,
|
||||
type LiveRuntimeEnvironmentAuthority
|
||||
} from '../../runtime/runtime-rpc-client'
|
||||
import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client'
|
||||
import {
|
||||
getRemoteRuntimePtyEnvironmentId,
|
||||
getRemoteRuntimeTerminalHandle,
|
||||
@@ -90,10 +83,6 @@ import {
|
||||
ptyShutdownLifecycleHandlers
|
||||
} from './pty-shutdown-data-suspension'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../../shared/legacy-terminal-attribution-env'
|
||||
|
||||
const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8
|
||||
const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
|
||||
@@ -103,8 +92,6 @@ const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
|
||||
const HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY = 2
|
||||
const HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT = 2
|
||||
const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const
|
||||
const AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE =
|
||||
'Remote agent recovery requires the same updated Orca server that accepted the launch. Update the workspace host and try again; no fallback launch was attempted.'
|
||||
|
||||
type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement'
|
||||
|
||||
@@ -371,7 +358,6 @@ export function createRemoteRuntimePtyTransport(
|
||||
// Why: reconnect retries must replay one host operation instead of creating
|
||||
// another fresh agent when the first response was lost.
|
||||
const agentCreateOperation = createAgentSessionCreateOperation()
|
||||
let agentSessionCreateAuthority: LiveRuntimeEnvironmentAuthority | null = null
|
||||
const outputProcessor = createPtyOutputProcessor({
|
||||
onTitleChange,
|
||||
onBell,
|
||||
@@ -866,16 +852,14 @@ export function createRemoteRuntimePtyTransport(
|
||||
environmentId: string,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs = 15_000,
|
||||
expectedRuntimeId?: string
|
||||
timeoutMs = 15_000
|
||||
): Promise<TResult> {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
|
||||
...(expectedRuntimeId ? { expectedRuntimeId } : {})
|
||||
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision
|
||||
})
|
||||
return unwrapRuntimeRpcResult(response as RuntimeRpcResponse<TResult>)
|
||||
}
|
||||
@@ -924,8 +908,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
reconcileExisting: boolean
|
||||
) => Promise<RemoteAgentSessionLaunchResult>,
|
||||
environmentId: string,
|
||||
expectedLifecycleEpoch: number,
|
||||
beforeReplay?: (timeoutMs: number) => Promise<void>
|
||||
expectedLifecycleEpoch: number
|
||||
): Promise<RemoteAgentSessionLaunchResult | null> {
|
||||
let retryAttempt = 0
|
||||
// Structured operations already carry their replay proof; ordinary terminal.create
|
||||
@@ -1010,9 +993,6 @@ export function createRemoteRuntimePtyTransport(
|
||||
break
|
||||
}
|
||||
try {
|
||||
if (reconcileExisting) {
|
||||
await beforeReplay?.(Math.min(5_000, createRemainingMs ?? 5_000))
|
||||
}
|
||||
return await invoke(Math.min(15_000, createRemainingMs ?? 15_000), reconcileExisting)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
@@ -1995,10 +1975,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
const commandToSend = options.command ?? command
|
||||
const startupCommandDeliveryToSend =
|
||||
options.startupCommandDelivery ?? startupCommandDelivery
|
||||
const envToSend = withLegacyTerminalAttributionDisabledEnv(options.env ?? env)
|
||||
const envToDeleteToSend = addLegacyTerminalAttributionDisableRequest(
|
||||
options.envToDelete ?? envToDelete
|
||||
)
|
||||
const envToSend = options.env ?? env
|
||||
const envToDeleteToSend = options.envToDelete ?? envToDelete
|
||||
const launchConfigToSend = options.launchConfig ?? launchConfig
|
||||
const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession
|
||||
const launchTokenToSend = options.launchToken ?? launchToken
|
||||
@@ -2026,7 +2004,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
presentation: 'background' as const,
|
||||
...(activate === true ? { activate: true } : {})
|
||||
}
|
||||
const legacyCreate = ({ authority }: { authority: LiveRuntimeEnvironmentAuthority }) =>
|
||||
const legacyCreate = () =>
|
||||
createWithUnknownOutcomeRecovery(
|
||||
'terminal',
|
||||
(timeoutMs, reconcileExisting) =>
|
||||
@@ -2037,49 +2015,13 @@ export function createRemoteRuntimePtyTransport(
|
||||
...legacyCreateParams,
|
||||
...(reconcileExisting ? { reconcileExisting: true } : {})
|
||||
},
|
||||
timeoutMs,
|
||||
authority.runtimeId
|
||||
timeoutMs
|
||||
),
|
||||
createEnvironmentId,
|
||||
connectLifecycleEpoch
|
||||
)
|
||||
const requiredAgentSessionCapabilities: readonly RuntimeCapability[] =
|
||||
resumeProviderSessionToSend && launchAgentToSend === 'omp'
|
||||
? [AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY]
|
||||
: []
|
||||
const revalidateAgentSessionReplay = async (
|
||||
timeoutMs: number,
|
||||
authority: LiveRuntimeEnvironmentAuthority
|
||||
): Promise<void> => {
|
||||
const status = await callRuntimeForEnvironment<RuntimeStatus>(
|
||||
createEnvironmentId,
|
||||
'status.get',
|
||||
undefined,
|
||||
timeoutMs,
|
||||
authority.runtimeId
|
||||
)
|
||||
const requiredCapabilities = [
|
||||
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
...requiredAgentSessionCapabilities
|
||||
]
|
||||
const capabilities = Array.isArray(status.capabilities) ? status.capabilities : []
|
||||
if (
|
||||
status.runtimeId !== authority.runtimeId ||
|
||||
requiredCapabilities.some((capability) => !capabilities.includes(capability))
|
||||
) {
|
||||
throw new Error(AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
}
|
||||
const hostAuthorityCreate = (authority: LiveRuntimeEnvironmentAuthority) => {
|
||||
if (
|
||||
agentSessionCreateAuthority &&
|
||||
agentSessionCreateAuthority.runtimeId !== authority.runtimeId
|
||||
) {
|
||||
throw new Error(AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
agentSessionCreateAuthority ??= authority
|
||||
return createWithUnknownOutcomeRecovery(
|
||||
const hostAuthorityCreate = () =>
|
||||
createWithUnknownOutcomeRecovery(
|
||||
'agent-session',
|
||||
(timeoutMs) =>
|
||||
resumeProviderSessionToSend
|
||||
@@ -2101,8 +2043,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
placement: { tabId, leafId },
|
||||
presentation: 'background'
|
||||
},
|
||||
timeoutMs,
|
||||
authority.runtimeId
|
||||
timeoutMs
|
||||
)
|
||||
: callRuntimeForEnvironment<RuntimeCreateAgentSessionResult>(
|
||||
createEnvironmentId,
|
||||
@@ -2124,37 +2065,23 @@ export function createRemoteRuntimePtyTransport(
|
||||
},
|
||||
agentCreateOperation.clientOperationId
|
||||
),
|
||||
timeoutMs,
|
||||
authority.runtimeId
|
||||
timeoutMs
|
||||
),
|
||||
createEnvironmentId,
|
||||
connectLifecycleEpoch,
|
||||
(timeoutMs) => revalidateAgentSessionReplay(timeoutMs, authority)
|
||||
connectLifecycleEpoch
|
||||
)
|
||||
}
|
||||
const created = launchAgentToSend
|
||||
? agentSessionRequiresHostAuthorityReplay
|
||||
? agentSessionCreateAuthority
|
||||
? await hostAuthorityCreate(agentSessionCreateAuthority)
|
||||
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
|
||||
environmentId: createEnvironmentId,
|
||||
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
|
||||
hostAuthority: hostAuthorityCreate,
|
||||
requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities,
|
||||
legacy: legacyCreate
|
||||
})
|
||||
? await hostAuthorityCreate()
|
||||
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
|
||||
environmentId: createEnvironmentId,
|
||||
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
|
||||
hostAuthority: hostAuthorityCreate,
|
||||
requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities,
|
||||
...(resumeProviderSessionToSend && launchAgentToSend === 'omp'
|
||||
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
|
||||
: {}),
|
||||
legacy: legacyCreate
|
||||
})
|
||||
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
|
||||
environmentId: createEnvironmentId,
|
||||
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
|
||||
legacy: legacyCreate
|
||||
})
|
||||
: await legacyCreate()
|
||||
if (!created) {
|
||||
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
|
||||
connecting = false
|
||||
@@ -2230,21 +2157,13 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
|
||||
connecting = false
|
||||
const message = runtimeTerminalErrorMessage(error)
|
||||
const recoverable = isRecoverableRemoteRuntimeConnectionError(
|
||||
toRemoteRuntimeClientErrorLike(error)
|
||||
)
|
||||
if (isRemoteTerminalGoneMessage(message)) {
|
||||
recovery.cancel()
|
||||
handleRemoteTerminalError(error)
|
||||
} else if (
|
||||
recoverable ||
|
||||
terminalCreateNeedsReconciliation ||
|
||||
agentSessionRequiresHostAuthorityReplay
|
||||
isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))
|
||||
) {
|
||||
recovery.markDisconnected()
|
||||
if (!recoverable) {
|
||||
surfaceErrorMessage(message)
|
||||
}
|
||||
} else {
|
||||
recovery.cancel()
|
||||
emitRecoveryState()
|
||||
|
||||
@@ -378,8 +378,6 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
|
||||
)
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'remote-runtime',
|
||||
method: 'terminal.createAgentSession',
|
||||
params: expect.objectContaining({
|
||||
clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
|
||||
@@ -422,7 +420,7 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
capabilities: []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -444,7 +442,6 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
|
||||
|
||||
expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedRuntimeId: 'old-runtime',
|
||||
method: 'terminal.create',
|
||||
params: expect.objectContaining({
|
||||
worktree: 'id:wt-1',
|
||||
|
||||
@@ -3,11 +3,6 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv
|
||||
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
|
||||
import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
createAgentSessionCreateOperation,
|
||||
toAgentLaunchPreferences,
|
||||
@@ -38,8 +33,7 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
|
||||
const launchPreferences = toAgentLaunchPreferences(args.sessionOptions)
|
||||
return await runRemoteAgentSessionLaunch({
|
||||
environmentId: args.environmentId,
|
||||
requiredHostAuthorityCapabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY],
|
||||
hostAuthority: (authority) =>
|
||||
hostAuthority: () =>
|
||||
operation.run((clientOperationId) =>
|
||||
callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>(
|
||||
{ kind: 'environment', environmentId: args.environmentId },
|
||||
@@ -58,14 +52,10 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
|
||||
},
|
||||
clientOperationId
|
||||
),
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
}
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
),
|
||||
legacy: ({ skipCompatibilityCheck, authority }) =>
|
||||
legacy: ({ skipCompatibilityCheck }) =>
|
||||
callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>(
|
||||
{ kind: 'environment', environmentId: args.environmentId },
|
||||
'terminal.create',
|
||||
@@ -75,8 +65,7 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
|
||||
...(args.legacy.startupCommandDelivery
|
||||
? { startupCommandDelivery: args.legacy.startupCommandDelivery }
|
||||
: {}),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(args.legacy.env),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
env: args.legacy.env,
|
||||
launchConfig: args.legacy.launchConfig,
|
||||
launchToken: args.legacy.launchToken,
|
||||
launchAgent: args.agent,
|
||||
@@ -85,12 +74,7 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
|
||||
leafId: args.leafId,
|
||||
presentation: 'background'
|
||||
},
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
skipCompatibilityCheck,
|
||||
expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
}
|
||||
{ timeoutMs: 15_000, skipCompatibilityCheck }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
activateAndRevealWorktree,
|
||||
@@ -24,35 +23,6 @@ function makeWebRuntimeWorktree() {
|
||||
}
|
||||
}
|
||||
|
||||
function makeWakeTerminalRuntimeCall(worktreeId: string) {
|
||||
return vi.fn(async (args: { method?: string }) => {
|
||||
if (args.method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (args.method === 'session.tabs.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: { tab: { id: 'host-tab-1::leaf-1', leafId: 'leaf-1' } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Activates and asserts a focusable tab appeared with no queued startup, returning its id. */
|
||||
function activateAndExpectNoRelaunch(
|
||||
worktreeId: string,
|
||||
@@ -566,7 +536,16 @@ describe('activateAndRevealWorktree', () => {
|
||||
|
||||
it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => {
|
||||
const worktree = makeWebRuntimeWorktree()
|
||||
const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id)
|
||||
const callRuntimeEnvironment = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tabId: 'host-tab-1', terminal: 'term_host' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -633,19 +612,9 @@ describe('activateAndRevealWorktree', () => {
|
||||
})
|
||||
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'status.get'
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'session.tabs.createTerminal'
|
||||
@@ -655,7 +624,10 @@ describe('activateAndRevealWorktree', () => {
|
||||
|
||||
it('respawns wake terminals on the explicit owner runtime when focus changed', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id)
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { tabId: 'host-tab-1', terminal: 'term_host' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -704,19 +676,9 @@ describe('activateAndRevealWorktree', () => {
|
||||
})
|
||||
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
selector: 'owner-runtime',
|
||||
method: 'status.get'
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'owner-runtime',
|
||||
method: 'session.tabs.createTerminal'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type { Worktree } from '../../../shared/types'
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
|
||||
@@ -49,43 +48,21 @@ function makeWorktree(): Worktree {
|
||||
describe('empty remote worktree activation', () => {
|
||||
it('creates a host terminal when waking an empty remote workspace', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn(async (args: { method?: string }) => {
|
||||
if (args.method === 'status.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] }
|
||||
}
|
||||
}
|
||||
if (args.method === 'session.tabs.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: worktree.id,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
tab: {
|
||||
type: 'terminal',
|
||||
id: 'host-tab-1::leaf-1',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal 1',
|
||||
terminal: 'term_host',
|
||||
status: 'ready',
|
||||
isActive: true
|
||||
},
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
}
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
tab: {
|
||||
type: 'terminal',
|
||||
id: 'host-tab-1::leaf-1',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal 1',
|
||||
terminal: 'term_host',
|
||||
status: 'ready',
|
||||
isActive: true
|
||||
},
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
}
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
@@ -123,18 +100,10 @@ describe('empty remote worktree activation', () => {
|
||||
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'status.get'
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'session.tabs.createTerminal',
|
||||
|
||||
@@ -12,8 +12,7 @@ export async function callAbortableRuntimeEnvironment(
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
signal: AbortSignal,
|
||||
expectedEnvironmentPairingRevision?: number,
|
||||
expectedRuntimeId?: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw createRuntimeRpcAbortError()
|
||||
@@ -48,14 +47,7 @@ export async function callAbortableRuntimeEnvironment(
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void window.api.runtimeEnvironments
|
||||
.subscribe(
|
||||
{
|
||||
selector: environmentId,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
expectedEnvironmentPairingRevision,
|
||||
expectedRuntimeId
|
||||
},
|
||||
{ selector: environmentId, method, params, timeoutMs, expectedEnvironmentPairingRevision },
|
||||
{
|
||||
onResponse: (response) => finish(() => resolve(response)),
|
||||
onError: (error) => finish(() => reject(new Error(error.message))),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ probe: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
supportsCapability: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-rpc-client', () => ({
|
||||
RuntimeRpcCallError: class RuntimeRpcCallError extends Error {
|
||||
@@ -10,110 +12,75 @@ vi.mock('./runtime-rpc-client', () => ({
|
||||
this.code = response.error.code
|
||||
}
|
||||
},
|
||||
probeLiveRuntimeEnvironmentCapabilities: mocks.probe
|
||||
runtimeEnvironmentSupportsCapability: mocks.supportsCapability
|
||||
}))
|
||||
|
||||
import { RuntimeRpcCallError } from './runtime-rpc-client'
|
||||
import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch'
|
||||
|
||||
const authority = {
|
||||
runtimeId: 'runtime-1',
|
||||
expectedEnvironmentPairingRevision: 7,
|
||||
capabilities: [
|
||||
'terminal.attribution-removed.v1',
|
||||
'agent-session.host-authority.v1',
|
||||
'agent-session.omp-resume-path.v1'
|
||||
]
|
||||
} as const
|
||||
|
||||
describe('remote agent-session launch routing', () => {
|
||||
beforeEach(() => {
|
||||
mocks.probe.mockReset()
|
||||
mocks.probe.mockResolvedValue({ supported: true, authority })
|
||||
mocks.supportsCapability.mockReset()
|
||||
})
|
||||
|
||||
it('uses one live all-capability probe before host authority', async () => {
|
||||
it('uses host authority only when the host advertises it', async () => {
|
||||
const hostAuthority = vi.fn().mockResolvedValue('structured')
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({
|
||||
environmentId: 'env-1',
|
||||
expectedEnvironmentPairingRevision: 7,
|
||||
hostAuthority,
|
||||
requiredHostAuthorityCapabilities: ['agent-session.omp-resume-path.v1'],
|
||||
hostAuthorityCapability: 'agent-session.omp-resume-path.v1',
|
||||
legacy
|
||||
})
|
||||
).resolves.toBe('structured')
|
||||
|
||||
expect(mocks.probe).toHaveBeenCalledWith({
|
||||
environmentId: 'env-1',
|
||||
requiredCapabilities: [
|
||||
'terminal.attribution-removed.v1',
|
||||
'agent-session.host-authority.v1',
|
||||
'agent-session.omp-resume-path.v1'
|
||||
],
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: 7
|
||||
})
|
||||
expect(hostAuthority).toHaveBeenCalledWith(authority)
|
||||
expect(mocks.supportsCapability).toHaveBeenCalledWith(
|
||||
'env-1',
|
||||
'agent-session.omp-resume-path.v1'
|
||||
)
|
||||
expect(hostAuthority).toHaveBeenCalledOnce()
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fenced legacy when a structured-only capability is absent', async () => {
|
||||
mocks.probe.mockResolvedValue({ supported: false, authority })
|
||||
it('preserves the exact legacy path when the capability is absent', async () => {
|
||||
const hostAuthority = vi.fn().mockResolvedValue('structured')
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockResolvedValue(false)
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy })
|
||||
).resolves.toBe('legacy')
|
||||
expect(legacy).toHaveBeenCalledOnce()
|
||||
expect(hostAuthority).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps legacy behavior when a read-only capability probe fails', async () => {
|
||||
const hostAuthority = vi.fn().mockResolvedValue('structured')
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockRejectedValue(new Error('status temporarily unavailable'))
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy })
|
||||
).resolves.toBe('legacy')
|
||||
expect(legacy).toHaveBeenCalledOnce()
|
||||
expect(hostAuthority).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not bypass an incompatible runtime protocol', async () => {
|
||||
const compatibilityError = Object.assign(new Error('runtime incompatible'), {
|
||||
code: 'runtime_compat_block'
|
||||
})
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockRejectedValue(compatibilityError)
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({
|
||||
environmentId: 'env-1',
|
||||
hostAuthority,
|
||||
requiredHostAuthorityCapabilities: ['agent-session.omp-resume-path.v1'],
|
||||
hostAuthority: vi.fn(),
|
||||
legacy
|
||||
})
|
||||
).resolves.toBe('legacy')
|
||||
|
||||
expect(hostAuthority).not.toHaveBeenCalled()
|
||||
expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority })
|
||||
})
|
||||
|
||||
it('fails closed without attribution-removal capability evidence', async () => {
|
||||
mocks.probe.mockResolvedValue({
|
||||
supported: false,
|
||||
authority: { ...authority, capabilities: ['agent-session.host-authority.v1'] }
|
||||
})
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy })
|
||||
).rejects.toThrow('Update the host and try again')
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the live capability probe fails', async () => {
|
||||
mocks.probe.mockRejectedValue(
|
||||
Object.assign(new Error('status temporarily unavailable'), { code: 'runtime_timeout' })
|
||||
)
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
const result = expect(
|
||||
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy })
|
||||
).rejects
|
||||
await result.toThrow('Update the host and try again')
|
||||
await result.toMatchObject({ code: 'runtime_timeout' })
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an incompatible runtime protocol error', async () => {
|
||||
const compatibilityError = Object.assign(new Error('runtime incompatible'), {
|
||||
code: 'runtime_compat_block'
|
||||
})
|
||||
mocks.probe.mockRejectedValue(compatibilityError)
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy })
|
||||
).rejects.toBe(compatibilityError)
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -121,6 +88,7 @@ describe('remote agent-session launch routing', () => {
|
||||
it('never downgrades after structured dispatch has started', async () => {
|
||||
const structuredError = new Error('structured response was lost')
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({
|
||||
@@ -132,13 +100,14 @@ describe('remote agent-session launch routing', () => {
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fenced legacy for the pre-side-effect lower-owner response', async () => {
|
||||
it('uses legacy only for the host pre-side-effect lower-owner response', async () => {
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockResolvedValue(true)
|
||||
const legacyRequired = new RuntimeRpcCallError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: { code: 'agent_session_legacy_required', message: 'legacy required' }
|
||||
})
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({
|
||||
@@ -147,16 +116,17 @@ describe('remote agent-session launch routing', () => {
|
||||
legacy
|
||||
})
|
||||
).resolves.toBe('legacy')
|
||||
expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority })
|
||||
expect(legacy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not downgrade when a replacement host rejects the structured method', async () => {
|
||||
it('uses legacy when a replaced old host does not recognize the structured method', async () => {
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
mocks.supportsCapability.mockResolvedValue(true)
|
||||
const methodNotFound = new RuntimeRpcCallError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' }
|
||||
})
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
await expect(
|
||||
runRemoteAgentSessionLaunch({
|
||||
@@ -164,16 +134,16 @@ describe('remote agent-session launch routing', () => {
|
||||
hostAuthority: vi.fn().mockRejectedValue(methodNotFound),
|
||||
legacy
|
||||
})
|
||||
).rejects.toBe(methodNotFound)
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
).resolves.toBe('legacy')
|
||||
expect(legacy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('probes and fences legacy when no structured form exists', async () => {
|
||||
it('uses legacy directly when no structured form exists', async () => {
|
||||
const legacy = vi.fn().mockResolvedValue('legacy')
|
||||
|
||||
await expect(runRemoteAgentSessionLaunch({ environmentId: 'env-1', legacy })).resolves.toBe(
|
||||
'legacy'
|
||||
)
|
||||
expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority })
|
||||
expect(mocks.supportsCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,46 @@
|
||||
import { AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from '../../../shared/agent-session-host-authority'
|
||||
import {
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../shared/protocol-version'
|
||||
import { TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
probeLiveRuntimeEnvironmentCapabilities,
|
||||
RuntimeRpcCallError,
|
||||
type LiveRuntimeEnvironmentAuthority
|
||||
} from './runtime-rpc-client'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import { RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
|
||||
import { isRuntimeCompatBlockError } from './runtime-protocol-compat'
|
||||
|
||||
export async function runRemoteAgentSessionLaunch<TResult>(args: {
|
||||
environmentId: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
hostAuthority?: (authority: LiveRuntimeEnvironmentAuthority) => Promise<TResult>
|
||||
requiredHostAuthorityCapabilities?: readonly RuntimeCapability[]
|
||||
legacy: (options: {
|
||||
skipCompatibilityCheck: boolean
|
||||
authority: LiveRuntimeEnvironmentAuthority
|
||||
}) => Promise<TResult>
|
||||
hostAuthority?: () => Promise<TResult>
|
||||
hostAuthorityCapability?: RuntimeCapability
|
||||
legacy: (options: { skipCompatibilityCheck: boolean }) => Promise<TResult>
|
||||
}): Promise<TResult> {
|
||||
const requiredCapabilities = [
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
...(args.hostAuthority ? [AGENT_SESSION_HOST_AUTHORITY_CAPABILITY] : []),
|
||||
...(args.requiredHostAuthorityCapabilities ?? [])
|
||||
]
|
||||
let probe: Awaited<ReturnType<typeof probeLiveRuntimeEnvironmentCapabilities>>
|
||||
if (!args.hostAuthority) {
|
||||
return await args.legacy({ skipCompatibilityCheck: false })
|
||||
}
|
||||
let supported: boolean
|
||||
try {
|
||||
probe = await probeLiveRuntimeEnvironmentCapabilities({
|
||||
environmentId: args.environmentId,
|
||||
requiredCapabilities,
|
||||
timeoutMs: 15_000,
|
||||
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision
|
||||
})
|
||||
supported = await runtimeEnvironmentSupportsCapability(
|
||||
args.environmentId,
|
||||
args.hostAuthorityCapability ?? AGENT_SESSION_HOST_AUTHORITY_CAPABILITY
|
||||
)
|
||||
} catch (error) {
|
||||
if (isRuntimeCompatBlockError(error)) {
|
||||
throw error
|
||||
}
|
||||
const code = error && typeof error === 'object' ? Reflect.get(error, 'code') : undefined
|
||||
// Why: unknown-outcome recovery still needs to classify a transient failed probe.
|
||||
throw Object.assign(
|
||||
new Error(TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, { cause: error }),
|
||||
typeof code === 'string' ? { code } : {}
|
||||
)
|
||||
// Why: a failed read-only probe has not launched anything, so preserving
|
||||
// the legacy path cannot duplicate an agent and keeps transient upgrades neutral.
|
||||
return await args.legacy({ skipCompatibilityCheck: true })
|
||||
}
|
||||
if (!probe.authority.capabilities.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY)) {
|
||||
throw new Error(TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
if (!args.hostAuthority || !probe.supported) {
|
||||
return await args.legacy({ skipCompatibilityCheck: true, authority: probe.authority })
|
||||
// Why: choose before invoking either path; an ambiguous structured outcome
|
||||
// must never trigger a legacy retry that could spawn a duplicate.
|
||||
if (!supported) {
|
||||
return await args.legacy({ skipCompatibilityCheck: true })
|
||||
}
|
||||
try {
|
||||
return await args.hostAuthority(probe.authority)
|
||||
return await args.hostAuthority()
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcCallError && error.code === 'agent_session_legacy_required') {
|
||||
return await args.legacy({ skipCompatibilityCheck: true, authority: probe.authority })
|
||||
if (
|
||||
error instanceof RuntimeRpcCallError &&
|
||||
(error.code === 'agent_session_legacy_required' || error.code === 'method_not_found')
|
||||
) {
|
||||
// Why: both responses prove no structured side effect began: the new host rejected an old
|
||||
// lower owner before dispatch, or an old host never recognized the method.
|
||||
return await args.legacy({ skipCompatibilityCheck: true })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call'
|
||||
import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision'
|
||||
import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
|
||||
import { unwrapRuntimeRpcResult } from './runtime-rpc-result'
|
||||
|
||||
export type LiveRuntimeEnvironmentAuthority = Readonly<{
|
||||
runtimeId: string
|
||||
expectedEnvironmentPairingRevision: number | undefined
|
||||
capabilities: readonly RuntimeCapability[]
|
||||
}>
|
||||
|
||||
export async function probeLiveRuntimeEnvironmentCapabilities(args: {
|
||||
environmentId: string
|
||||
requiredCapabilities: readonly RuntimeCapability[]
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}): Promise<{ supported: boolean; authority: LiveRuntimeEnvironmentAuthority }> {
|
||||
const environmentId = args.environmentId.trim()
|
||||
const expectedEnvironmentPairingRevision = captureRuntimeEnvironmentRequestRevision(
|
||||
environmentId,
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
const response = await callRuntimeEnvironmentWithRevision({
|
||||
environmentId,
|
||||
method: 'status.get',
|
||||
params: undefined,
|
||||
timeoutMs: args.timeoutMs,
|
||||
expectedEnvironmentPairingRevision
|
||||
})
|
||||
const status = unwrapRuntimeRpcResult<RuntimeStatus>(
|
||||
response as RuntimeRpcResponse<RuntimeStatus>
|
||||
)
|
||||
assertRuntimeStatusCompatible(status)
|
||||
const capabilities = status.capabilities ?? []
|
||||
return {
|
||||
supported: args.requiredCapabilities.every((capability) => capabilities.includes(capability)),
|
||||
authority: Object.freeze({
|
||||
runtimeId: status.runtimeId,
|
||||
expectedEnvironmentPairingRevision,
|
||||
capabilities: Object.freeze([...capabilities])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,7 @@ import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../shared/protocol-version'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
clearRuntimeCompatibilityCacheForTests,
|
||||
probeLiveRuntimeEnvironmentCapabilities
|
||||
} from './runtime-rpc-client'
|
||||
import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
@@ -66,57 +62,3 @@ it('captures the pairing revision before awaiting the compatibility probe', asyn
|
||||
expectedEnvironmentPairingRevision: 10
|
||||
})
|
||||
})
|
||||
|
||||
it('live capability probes ignore a warm cache and pin the captured pairing revision', async () => {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'env-live', createdAt: 1, pairingRevision: 20 }])
|
||||
runtimeEnvironmentCall
|
||||
.mockResolvedValueOnce({
|
||||
id: 'warm-status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-a',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'repo', ok: true, result: { repos: [] } })
|
||||
.mockImplementationOnce(async () => {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'env-live', createdAt: 1, pairingRevision: 21 }])
|
||||
return {
|
||||
id: 'live-status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-b',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
capabilities: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await callRuntimeRpc({ kind: 'environment', environmentId: 'env-live' }, 'repo.list')
|
||||
await expect(
|
||||
probeLiveRuntimeEnvironmentCapabilities({
|
||||
environmentId: 'env-live',
|
||||
requiredCapabilities: ['terminal.attribution-removed.v1']
|
||||
})
|
||||
).resolves.toEqual({
|
||||
supported: false,
|
||||
authority: {
|
||||
runtimeId: 'runtime-b',
|
||||
expectedEnvironmentPairingRevision: 20,
|
||||
capabilities: []
|
||||
}
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({
|
||||
selector: 'env-live',
|
||||
method: 'status.get',
|
||||
params: undefined,
|
||||
timeoutMs: undefined,
|
||||
expectedEnvironmentPairingRevision: 20
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,6 @@ export {
|
||||
RuntimeRpcCallError,
|
||||
unwrapRuntimeRpcResult
|
||||
} from './runtime-rpc-result'
|
||||
export { probeLiveRuntimeEnvironmentCapabilities } from './runtime-environment-authority'
|
||||
export type { LiveRuntimeEnvironmentAuthority } from './runtime-environment-authority'
|
||||
|
||||
const RUNTIME_COMPATIBILITY_CACHE_MAX = 32
|
||||
const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000
|
||||
@@ -56,7 +54,6 @@ export async function callRuntimeRpc<TResult>(
|
||||
skipCompatibilityCheck?: boolean
|
||||
signal?: AbortSignal
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
} = {}
|
||||
): Promise<TResult> {
|
||||
const expectedEnvironmentPairingRevision =
|
||||
@@ -91,8 +88,7 @@ export async function callRuntimeRpc<TResult>(
|
||||
params: nextParams,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal,
|
||||
expectedEnvironmentPairingRevision,
|
||||
expectedRuntimeId: options.expectedRuntimeId
|
||||
expectedEnvironmentPairingRevision
|
||||
})
|
||||
return unwrapRuntimeRpcResult<TResult>(response as RuntimeRpcResponse<TResult>)
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call'
|
||||
|
||||
it('preserves abort semantics for runtime-fenced requests', async () => {
|
||||
const controller = new AbortController()
|
||||
const unsubscribe = vi.fn()
|
||||
const call = vi.fn()
|
||||
const subscribe = vi.fn().mockResolvedValue({ unsubscribe, sendBinary: vi.fn() })
|
||||
vi.stubGlobal('window', { api: { runtimeEnvironments: { call, subscribe } } })
|
||||
|
||||
const request = callRuntimeEnvironmentWithRevision({
|
||||
environmentId: 'env-1',
|
||||
method: 'status.get',
|
||||
params: undefined,
|
||||
signal: controller.signal,
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
})
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalled())
|
||||
expect(subscribe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedRuntimeId: 'runtime-1' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
controller.abort()
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -7,7 +7,6 @@ export async function callRuntimeEnvironmentWithRevision(args: {
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}): Promise<unknown> {
|
||||
if (args.signal) {
|
||||
return callAbortableRuntimeEnvironment(
|
||||
@@ -16,8 +15,7 @@ export async function callRuntimeEnvironmentWithRevision(args: {
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.signal,
|
||||
args.expectedEnvironmentPairingRevision,
|
||||
args.expectedRuntimeId
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
}
|
||||
return window.api.runtimeEnvironments.call({
|
||||
@@ -25,7 +23,6 @@ export async function callRuntimeEnvironmentWithRevision(args: {
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
timeoutMs: args.timeoutMs,
|
||||
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision,
|
||||
expectedRuntimeId: args.expectedRuntimeId
|
||||
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,12 +56,9 @@ const mocks = vi.hoisted(() => ({
|
||||
deliverLaunchPromptToAgentTab: vi.fn(),
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
|
||||
getRuntimeEnvironmentIdForWorktree: vi.fn(),
|
||||
hasMaterializedWebRuntimeBrowserPage: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
hasMaterializedWebRuntimeBrowserPage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: mocks.getState,
|
||||
@@ -115,21 +112,6 @@ function makeSnapshot(): RuntimeMobileSessionTabsResult {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAttributionSafeStatus(capabilities: string[] = ['terminal.attribution-removed.v1']) {
|
||||
return {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
appVersion: '1.4.181',
|
||||
capabilities
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('refreshWebRuntimeSessionTabsSnapshot', () => {
|
||||
afterEach(() => {
|
||||
resetWebAgentSessionHandoffsForTests()
|
||||
@@ -1448,7 +1430,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
})
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeAttributionSafeStatus())
|
||||
.mockResolvedValueOnce({
|
||||
id: 'create',
|
||||
ok: true,
|
||||
@@ -1469,43 +1450,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
expect(selectedHosts).toEqual([RUNTIME_EXECUTION_HOST_ID])
|
||||
})
|
||||
|
||||
it('refuses session-tab creation when an older host drops terminal environment fields', async () => {
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { capabilities: ['mobile.tasks.v1'] }
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call: runtimeCall } }
|
||||
})
|
||||
|
||||
await expect(createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID })).resolves.toEqual({
|
||||
status: 'failed',
|
||||
message:
|
||||
'Creating terminals requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.'
|
||||
})
|
||||
|
||||
expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual(['status.get'])
|
||||
})
|
||||
|
||||
it('returns a failed outcome when attribution cleanup exceeds the deletion limit', async () => {
|
||||
const runtimeCall = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call: runtimeCall } }
|
||||
})
|
||||
|
||||
await expect(
|
||||
createWebRuntimeSessionTerminal({
|
||||
worktreeId: WORKTREE_ID,
|
||||
envToDelete: Array.from({ length: 32 }, (_, index) => `KEY_${index}`)
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: 'failed',
|
||||
message: 'Terminal environment deletion limit leaves no room for attribution cleanup'
|
||||
})
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ sessionKind: 'fresh' as const, activate: true },
|
||||
{ sessionKind: 'fresh' as const, activate: false },
|
||||
@@ -1525,7 +1469,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1614,7 +1558,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1678,7 +1622,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'runtime-1',
|
||||
method: 'terminal.createAgentSession',
|
||||
params: {
|
||||
clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
|
||||
@@ -1723,7 +1666,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => {
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeAttributionSafeStatus())
|
||||
.mockResolvedValueOnce({
|
||||
id: 'legacy-create',
|
||||
ok: true,
|
||||
@@ -1749,10 +1691,8 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
})
|
||||
).resolves.toEqual({ status: 'created' })
|
||||
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'runtime-1',
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
@@ -1760,8 +1700,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
targetGroupId: 'group-left',
|
||||
command: undefined,
|
||||
cwd: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
startupCommandDelivery: undefined,
|
||||
agent: 'codex',
|
||||
activate: false,
|
||||
@@ -1771,7 +1709,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([
|
||||
'status.get',
|
||||
'session.tabs.createTerminal',
|
||||
'session.tabs.list'
|
||||
])
|
||||
@@ -1788,7 +1725,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
})
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeAttributionSafeStatus())
|
||||
.mockResolvedValueOnce({
|
||||
id: 'create-terminal',
|
||||
ok: true,
|
||||
@@ -1845,7 +1781,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1906,7 +1842,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1959,7 +1895,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
capabilities: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1990,8 +1926,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'old-runtime',
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
@@ -1999,8 +1933,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
targetGroupId: 'group-left',
|
||||
command: undefined,
|
||||
cwd: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
startupCommandDelivery: undefined,
|
||||
launchAgent: 'codex',
|
||||
activate: false,
|
||||
@@ -2027,7 +1959,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
capabilities: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2062,8 +1994,6 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
expectedRuntimeId: 'old-runtime',
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
@@ -2071,8 +2001,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
targetGroupId: undefined,
|
||||
command: "codex resume 'session-1'",
|
||||
cwd: undefined,
|
||||
env: { CODEX_PROFILE: 'captured', ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
env: { CODEX_PROFILE: 'captured' },
|
||||
startupCommandDelivery: undefined,
|
||||
launchConfig: {
|
||||
agentCommand: 'codex',
|
||||
@@ -2101,7 +2030,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2142,14 +2071,9 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
|
||||
expect(methods).toEqual(['status.get', 'session.tabs.createTerminal', 'session.tabs.list'])
|
||||
expect(runtimeCall.mock.calls[1]?.[0]).toMatchObject({
|
||||
expectedRuntimeId: 'new-runtime',
|
||||
params: {
|
||||
command: "omp --resume '/custom/omp/project/session.jsonl'",
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: '/custom/omp',
|
||||
ORCA_ATTRIBUTION_BYPASS: '1'
|
||||
},
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
env: { PI_CODING_AGENT_DIR: '/custom/omp' },
|
||||
launchAgent: 'omp'
|
||||
}
|
||||
})
|
||||
@@ -2166,7 +2090,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2228,7 +2152,7 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
|
||||
capabilities: ['agent-session.host-authority.v1']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2858,25 +2782,17 @@ describe('splitWebRuntimeTerminal', () => {
|
||||
})
|
||||
|
||||
it('passes telemetry source to the host split while allowing the mirrored split event to be suppressed', async () => {
|
||||
const runtimeCall = vi.fn(async (request: { method: string }) =>
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { capabilities: ['terminal.attribution-removed.v1'] }
|
||||
}
|
||||
: {
|
||||
id: 'split',
|
||||
ok: true,
|
||||
result: {
|
||||
split: {
|
||||
handle: 'terminal-2',
|
||||
tabId: 'tab-1',
|
||||
paneRuntimeId: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'split',
|
||||
ok: true,
|
||||
result: {
|
||||
split: {
|
||||
handle: 'terminal-2',
|
||||
tabId: 'tab-1',
|
||||
paneRuntimeId: -1
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
@@ -2895,22 +2811,13 @@ describe('splitWebRuntimeTerminal', () => {
|
||||
consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-1', 'horizontal')
|
||||
).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'web-env-1',
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
method: 'status.get',
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'web-env-1',
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
method: 'terminal.split',
|
||||
params: {
|
||||
terminal: 'terminal-1',
|
||||
direction: 'horizontal',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
telemetrySource: 'keyboard'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
@@ -2919,19 +2826,11 @@ describe('splitWebRuntimeTerminal', () => {
|
||||
|
||||
it('does not track rejected host split RPCs', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const runtimeCall = vi.fn(async (request: { method: string }) =>
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { capabilities: ['terminal.attribution-removed.v1'] }
|
||||
}
|
||||
: {
|
||||
id: 'split',
|
||||
ok: false,
|
||||
error: { code: 'terminal_exited', message: 'Terminal exited' }
|
||||
}
|
||||
)
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'split',
|
||||
ok: false,
|
||||
error: { code: 'terminal_exited', message: 'Terminal exited' }
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
@@ -2944,53 +2843,23 @@ describe('splitWebRuntimeTerminal', () => {
|
||||
splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'context_menu')
|
||||
).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Terminal exited')
|
||||
expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call: runtimeCall } }
|
||||
})
|
||||
|
||||
expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1))
|
||||
expect(runtimeCall).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.split' })
|
||||
)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Update the host and try again')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => {
|
||||
const runtimeCall = vi.fn(async (request: { method: string }) =>
|
||||
request.method === 'status.get'
|
||||
? { id: 'status', ok: true, result: { capabilities: ['terminal.attribution-removed.v1'] } }
|
||||
: {
|
||||
id: 'split',
|
||||
ok: true,
|
||||
result: {
|
||||
split: {
|
||||
handle: 'terminal-2',
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'split',
|
||||
ok: true,
|
||||
result: {
|
||||
split: {
|
||||
handle: 'terminal-2',
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-2'
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
@@ -3005,7 +2874,7 @@ describe('splitWebRuntimeTerminal', () => {
|
||||
true
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { toast } from 'sonner'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import type {
|
||||
BrowserTabCreateResult,
|
||||
@@ -8,7 +9,6 @@ import type {
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeSessionTabCloseReason,
|
||||
RuntimeStatus,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalSplit
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
SleepingAgentLaunchConfig,
|
||||
AgentProviderSessionMetadata
|
||||
} from '../../../shared/agent-session-resume'
|
||||
import {
|
||||
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/protocol-version'
|
||||
import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type {
|
||||
AgentLaunchPreferences,
|
||||
AgentPromptDelivery,
|
||||
@@ -85,15 +82,6 @@ import {
|
||||
throwIfE2eWebRuntimeBrowserCapabilityUnavailable,
|
||||
throwIfE2eWebRuntimeBrowserReconciliationFails
|
||||
} from './web-runtime-browser-creation-e2e-fault'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable,
|
||||
hostSupportsTerminalSplitAttributionDisable,
|
||||
SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../shared/legacy-terminal-attribution-env'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export {
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
@@ -143,7 +131,6 @@ function captureRuntimeEnvironmentCall(
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedRuntimeId?: string
|
||||
}) => Promise<RuntimeRpcResponse<unknown>> {
|
||||
return (args) =>
|
||||
window.api.runtimeEnvironments.call({
|
||||
@@ -278,14 +265,6 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
}
|
||||
const intentOwner = captureWebSessionIntentOwner(environmentId)
|
||||
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
|
||||
const assertSessionTabCreateAttributionDisableSupported = async (): Promise<RuntimeStatus> => {
|
||||
const response = await callEnvironment({ method: 'status.get', timeoutMs: 15_000 })
|
||||
const status = unwrapRuntimeRpcResult(response as RuntimeRpcResponse<RuntimeStatus>)
|
||||
if (!hostSupportsSessionTabTerminalCreateAttributionDisable(status)) {
|
||||
throw new Error(SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
if (args.selectWorktree !== false) {
|
||||
selectWebRuntimeSessionWorktree(args.worktreeId, environmentId)
|
||||
@@ -294,8 +273,6 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
let createdTabId: string | undefined
|
||||
let createdLeafId: string | undefined
|
||||
try {
|
||||
const env = withLegacyTerminalAttributionDisabledEnv(args.env)
|
||||
const envToDelete = addLegacyTerminalAttributionDisableRequest(args.envToDelete)
|
||||
const agent = args.launchAgent ?? args.agent
|
||||
const agentArgsOverride =
|
||||
args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs
|
||||
@@ -307,7 +284,7 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
? undefined
|
||||
: args.agentSessionKind === 'resume'
|
||||
? args.providerSession
|
||||
? async (authority: { runtimeId: string }) =>
|
||||
? async () =>
|
||||
unwrapRuntimeRpcResult(
|
||||
(await callEnvironment({
|
||||
method: 'terminal.ensureAgentSession',
|
||||
@@ -325,12 +302,11 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
: {}),
|
||||
presentation: 'background'
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
timeoutMs: 15_000
|
||||
})) as RuntimeRpcResponse<RuntimeEnsureAgentSessionResult>
|
||||
)
|
||||
: undefined
|
||||
: async (authority: { runtimeId: string }) =>
|
||||
: async () =>
|
||||
await createAgentSessionCreateOperation().run(async (clientOperationId) =>
|
||||
unwrapRuntimeRpcResult(
|
||||
(await callEnvironment({
|
||||
@@ -353,8 +329,7 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
},
|
||||
clientOperationId
|
||||
),
|
||||
timeoutMs: 15_000,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
timeoutMs: 15_000
|
||||
})) as RuntimeRpcResponse<RuntimeCreateAgentSessionResult>
|
||||
)
|
||||
)
|
||||
@@ -362,15 +337,11 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
terminal: CreatedAgentTerminalIdentity
|
||||
}>({
|
||||
environmentId,
|
||||
expectedEnvironmentPairingRevision: intentOwner.pairingRevision,
|
||||
...(hostAuthority ? { hostAuthority } : {}),
|
||||
requiredHostAuthorityCapabilities: [
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
...(args.agentSessionKind === 'resume' && agent === 'omp'
|
||||
? [AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY]
|
||||
: [])
|
||||
],
|
||||
legacy: async ({ authority }) => {
|
||||
...(args.agentSessionKind === 'resume' && agent === 'omp'
|
||||
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
|
||||
: {}),
|
||||
legacy: async () => {
|
||||
const response = await callEnvironment({
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
@@ -379,8 +350,8 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
targetGroupId: args.targetGroupId,
|
||||
command: args.command,
|
||||
cwd: args.cwd,
|
||||
env,
|
||||
...(envToDelete ? { envToDelete } : {}),
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
startupCommandDelivery: args.startupCommandDelivery,
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
...(args.launchToken ? { launchToken: args.launchToken } : {}),
|
||||
@@ -392,8 +363,7 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
select: args.activate !== false,
|
||||
navigation: 'caller'
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
const legacyCreated = unwrapRuntimeRpcResult(
|
||||
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
|
||||
@@ -425,7 +395,6 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const status = await assertSessionTabCreateAttributionDisableSupported()
|
||||
const response = await callEnvironment({
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
@@ -434,8 +403,8 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
targetGroupId: args.targetGroupId,
|
||||
command: args.command,
|
||||
cwd: args.cwd,
|
||||
env,
|
||||
...(envToDelete ? { envToDelete } : {}),
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
startupCommandDelivery: args.startupCommandDelivery,
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
...(args.launchToken ? { launchToken: args.launchToken } : {}),
|
||||
@@ -445,8 +414,7 @@ async function createWebRuntimeSessionTerminalResult(
|
||||
select: args.activate !== false,
|
||||
navigation: 'caller'
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
expectedRuntimeId: status.runtimeId
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
const created = unwrapRuntimeRpcResult(
|
||||
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
|
||||
@@ -1218,28 +1186,16 @@ export function splitWebRuntimeTerminal(
|
||||
direction,
|
||||
pendingMirrorSuppressionId
|
||||
)
|
||||
const callEnvironment = captureRuntimeEnvironmentCall(environmentId)
|
||||
void callEnvironment({
|
||||
method: 'status.get',
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
.then((response) => {
|
||||
const status = unwrapRuntimeRpcResult(response as RuntimeRpcResponse<RuntimeStatus>)
|
||||
if (!hostSupportsTerminalSplitAttributionDisable(status)) {
|
||||
throw new Error(TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
return callEnvironment({
|
||||
method: 'terminal.split',
|
||||
params: {
|
||||
terminal: remote.handle,
|
||||
direction,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
telemetrySource
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
expectedRuntimeId: status.runtimeId
|
||||
})
|
||||
void window.api.runtimeEnvironments
|
||||
.call({
|
||||
selector: environmentId,
|
||||
method: 'terminal.split',
|
||||
params: {
|
||||
terminal: remote.handle,
|
||||
direction,
|
||||
telemetrySource
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
.then((response) => {
|
||||
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }>)
|
||||
@@ -1247,6 +1203,8 @@ export function splitWebRuntimeTerminal(
|
||||
.catch((error) => {
|
||||
releasePendingMirrorSuppression()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Why: a split that fails only in the console leaves the user with a pane that silently
|
||||
// never appears.
|
||||
toast.error(message)
|
||||
console.warn('[web-runtime-session] failed to split terminal:', message)
|
||||
})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable,
|
||||
hostSupportsTerminalCreateAttributionDisable,
|
||||
hostSupportsTerminalSplitAttributionDisable,
|
||||
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY,
|
||||
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from './legacy-terminal-attribution-env'
|
||||
|
||||
describe('legacy terminal attribution environment', () => {
|
||||
it('adds the inert old-host gate once', () => {
|
||||
expect(addLegacyTerminalAttributionDisableRequest(['CODEX_HOME'])).toEqual([
|
||||
'CODEX_HOME',
|
||||
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
|
||||
])
|
||||
expect(
|
||||
addLegacyTerminalAttributionDisableRequest([LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY])
|
||||
).toEqual([LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY])
|
||||
})
|
||||
|
||||
it('fails instead of dropping a caller deletion at the wire limit', () => {
|
||||
const full = Array.from({ length: 32 }, (_, index) => `KEY_${index}`)
|
||||
expect(() => addLegacyTerminalAttributionDisableRequest(full)).toThrow(
|
||||
'Terminal environment deletion limit'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the wrapper bypass without mutating caller env', () => {
|
||||
const env = { CODEX_HOME: '/tmp/codex' }
|
||||
expect(withLegacyTerminalAttributionDisabledEnv(env)).toEqual({
|
||||
CODEX_HOME: '/tmp/codex',
|
||||
[LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY]: '1'
|
||||
})
|
||||
expect(env).toEqual({ CODEX_HOME: '/tmp/codex' })
|
||||
})
|
||||
|
||||
it('accepts only split hosts that removed attribution for every pane owner', () => {
|
||||
expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: '1.4.18' })).toBe(false)
|
||||
expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: '1.4.19' })).toBe(false)
|
||||
expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: 'v1.4.181' })).toBe(false)
|
||||
expect(hostSupportsTerminalSplitAttributionDisable({ capabilities: ['mobile.tasks.v1'] })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
hostSupportsTerminalSplitAttributionDisable({
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts only capability-proven terminal creation hosts', () => {
|
||||
expect(hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: '1.4.89' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: '1.4.90' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: 'not-semver' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable({
|
||||
capabilities: ['workspace-run-context.v1']
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable({
|
||||
capabilities: ['terminal.quick-commands.v1']
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts capability-proven current hosts regardless of development version', () => {
|
||||
const status = {
|
||||
appVersion: '0.0.0-dev',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
expect(hostSupportsTerminalSplitAttributionDisable(status)).toBe(true)
|
||||
expect(hostSupportsTerminalCreateAttributionDisable(status)).toBe(true)
|
||||
expect(hostSupportsSessionTabTerminalCreateAttributionDisable(status)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed for malformed host status fields', () => {
|
||||
expect(
|
||||
hostSupportsSessionTabTerminalCreateAttributionDisable({
|
||||
appVersion: 1.49,
|
||||
capabilities: 'terminal.attribution-removed.v1'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hostSupportsTerminalSplitAttributionDisable({
|
||||
appVersion: {},
|
||||
capabilities: ['terminal.attribution-removed.v1', 1]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from './protocol-version'
|
||||
|
||||
export const LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY = 'ORCA_ENABLE_GIT_ATTRIBUTION'
|
||||
export const LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY = 'ORCA_ATTRIBUTION_BYPASS'
|
||||
export const TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE =
|
||||
'Terminal splitting requires an updated workspace host that can verify attribution removal for every pane owner. Update the host and try again.'
|
||||
export const TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating terminals requires an updated workspace host that can verify attribution removal. Update the host and try again.'
|
||||
export const MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating terminals from Orca Mobile requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.'
|
||||
export const SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating terminals requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.'
|
||||
const MAX_TERMINAL_ENV_DELETION_KEYS = 32
|
||||
|
||||
type TerminalAttributionHostStatus = {
|
||||
appVersion?: string
|
||||
capabilities?: string[]
|
||||
}
|
||||
|
||||
function readTerminalAttributionHostStatus(value: unknown): TerminalAttributionHostStatus {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const appVersion = Reflect.get(value, 'appVersion')
|
||||
const capabilities = Reflect.get(value, 'capabilities')
|
||||
return {
|
||||
...(typeof appVersion === 'string' ? { appVersion } : {}),
|
||||
...(Array.isArray(capabilities) && capabilities.every((entry) => typeof entry === 'string')
|
||||
? { capabilities }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function hostSupportsTerminalSplitAttributionDisable(value: unknown): boolean {
|
||||
const status = readTerminalAttributionHostStatus(value)
|
||||
// Why: only removal-capable hosts cover both runtime- and renderer-owned split targets.
|
||||
return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
export function hostSupportsTerminalCreateAttributionDisable(value: unknown): boolean {
|
||||
const status = readTerminalAttributionHostStatus(value)
|
||||
return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
export function hostSupportsSessionTabTerminalCreateAttributionDisable(value: unknown): boolean {
|
||||
const status = readTerminalAttributionHostStatus(value)
|
||||
return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
export function withLegacyTerminalAttributionDisabledEnv(
|
||||
env: Record<string, string> | undefined
|
||||
): Record<string, string> {
|
||||
return { ...env, [LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY]: '1' }
|
||||
}
|
||||
|
||||
export function addLegacyTerminalAttributionDisableRequest(
|
||||
envToDelete: readonly string[] | undefined
|
||||
): string[] | undefined {
|
||||
const deduplicated = [...new Set(envToDelete ?? [])]
|
||||
const gateIndex = deduplicated.indexOf(LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY)
|
||||
if (gateIndex !== -1) {
|
||||
deduplicated.splice(gateIndex, 1)
|
||||
}
|
||||
if (deduplicated.length >= MAX_TERMINAL_ENV_DELETION_KEYS) {
|
||||
throw new Error('Terminal environment deletion limit leaves no room for attribution cleanup')
|
||||
}
|
||||
return [...deduplicated, LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY]
|
||||
}
|
||||
@@ -84,9 +84,6 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY =
|
||||
'agent-session.host-authority.v1' as const
|
||||
export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
|
||||
'agent-session.omp-resume-path.v1' as const
|
||||
// Why: old host-authority launches cannot accept env deletions and may still inject attribution.
|
||||
export const TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY =
|
||||
'terminal.attribution-removed.v1' as const
|
||||
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
|
||||
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
|
||||
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
|
||||
@@ -124,7 +121,6 @@ export const RUNTIME_CAPABILITIES = [
|
||||
REMOTE_SERVER_UPDATE_CAPABILITY,
|
||||
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
|
||||
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
|
||||
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
|
||||
|
||||
@@ -35,25 +35,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('subscribeRemoteRuntimeRequest', () => {
|
||||
it('serializes the expected runtime fence on subscription requests', async () => {
|
||||
const server = await createSubscriptionServer()
|
||||
const subscription = await subscribeRemoteRuntimeRequest(
|
||||
server.pairing,
|
||||
'terminal.subscribe',
|
||||
{},
|
||||
1000,
|
||||
{ onResponse: vi.fn(), onError: vi.fn() },
|
||||
undefined,
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
|
||||
await expect(server.nextRequest).resolves.toMatchObject({
|
||||
method: 'terminal.subscribe',
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
})
|
||||
subscription.close()
|
||||
})
|
||||
|
||||
it('includes WebSocket close details when subscription admission is rejected', async () => {
|
||||
const server = await createClosingServer(1013, 'Maximum connections reached')
|
||||
|
||||
@@ -354,7 +335,6 @@ async function createSubscriptionServer(
|
||||
pairing: PairingOffer
|
||||
nextBinary: Promise<Uint8Array>
|
||||
nextAuth: Promise<unknown>
|
||||
nextRequest: Promise<Record<string, unknown>>
|
||||
}> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
let resolveBinary: (bytes: Uint8Array) => void = () => {}
|
||||
@@ -365,10 +345,6 @@ async function createSubscriptionServer(
|
||||
const nextAuth = new Promise<unknown>((resolve) => {
|
||||
resolveAuth = resolve
|
||||
})
|
||||
let resolveRequest: (request: Record<string, unknown>) => void = () => {}
|
||||
const nextRequest = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true })
|
||||
servers.push(wss)
|
||||
|
||||
@@ -410,8 +386,7 @@ async function createSubscriptionServer(
|
||||
return
|
||||
}
|
||||
|
||||
const request = JSON.parse(plaintext) as { id: string } & Record<string, unknown>
|
||||
resolveRequest(request)
|
||||
const request = JSON.parse(plaintext) as { id: string }
|
||||
sendEncrypted(ws, sharedKey, {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
@@ -444,7 +419,7 @@ async function createSubscriptionServer(
|
||||
if (!pairing) {
|
||||
throw new Error('Failed to create test pairing')
|
||||
}
|
||||
return { pairing, nextBinary, nextAuth, nextRequest }
|
||||
return { pairing, nextBinary, nextAuth }
|
||||
}
|
||||
|
||||
function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void {
|
||||
|
||||
@@ -497,16 +497,14 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
callbacks: RemoteRuntimeSubscriptionCallbacks<TResult>,
|
||||
livenessOptions?: RemoteRuntimeSocketLivenessOptions,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
livenessOptions?: RemoteRuntimeSocketLivenessOptions
|
||||
): Promise<RemoteRuntimeSubscription> {
|
||||
const requestId = randomUUID()
|
||||
const serializedRequest = serializeRemoteRuntimeRpcRequest({
|
||||
requestId,
|
||||
deviceToken: pairing.deviceToken,
|
||||
method,
|
||||
params,
|
||||
envelope
|
||||
params
|
||||
})
|
||||
const serializedAuth = serializeRemoteRuntimePayload({
|
||||
type: 'e2ee_auth',
|
||||
|
||||
@@ -71,7 +71,6 @@ export function serializeRemoteRuntimeRpcRequest(args: {
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
expectedRuntimeId: args.envelope?.expectedRuntimeId,
|
||||
orchestrationCapability: args.envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: args.envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: args.envelope?.orchestrationRequestId,
|
||||
|
||||
@@ -12,7 +12,6 @@ export function admitSharedControlSubscription(args: {
|
||||
deviceToken: string
|
||||
method: string
|
||||
params: unknown
|
||||
expectedRuntimeId?: string
|
||||
}): number {
|
||||
if (args.subscriptions.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) {
|
||||
throw new RemoteRuntimeClientError(
|
||||
@@ -34,18 +33,12 @@ export function admitSharedControlSubscription(args: {
|
||||
return retainedParamsBytes
|
||||
}
|
||||
|
||||
function serializeRequest(args: {
|
||||
deviceToken: string
|
||||
method: string
|
||||
params: unknown
|
||||
expectedRuntimeId?: string
|
||||
}): void {
|
||||
function serializeRequest(args: { deviceToken: string; method: string; params: unknown }): void {
|
||||
serializeRemoteRuntimeRpcRequest({
|
||||
requestId: '00000000-0000-4000-8000-000000000000',
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
envelope: { expectedRuntimeId: args.expectedRuntimeId }
|
||||
params: args.params
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
private sharedKey: Uint8Array | null = null
|
||||
private socketCleanup: (() => void) | null = null
|
||||
private readonly reconnect = new SharedControlReconnectScheduler()
|
||||
private readonly stableReset: SharedControlReadyStableResetTimer
|
||||
private readonly readyStableReset: SharedControlReadyStableResetTimer
|
||||
private intentionallyClosed = false
|
||||
private lastConnectedAt: number | null = null
|
||||
private lastClose: { code: number; reason: string } | null = null
|
||||
@@ -51,13 +51,15 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
|
||||
constructor(
|
||||
private readonly pairing: PairingOffer,
|
||||
private readonly opts: {
|
||||
private readonly options: {
|
||||
environmentId?: string
|
||||
reconnectStableResetMs?: number
|
||||
liveness?: RemoteRuntimeSocketLivenessOptions
|
||||
} = {}
|
||||
) {
|
||||
this.stableReset = new SharedControlReadyStableResetTimer(opts.reconnectStableResetMs ?? 30_000)
|
||||
this.readyStableReset = new SharedControlReadyStableResetTimer(
|
||||
options.reconnectStableResetMs ?? 30_000
|
||||
)
|
||||
}
|
||||
|
||||
request<TResult>(
|
||||
@@ -83,15 +85,13 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
callbacks: SharedControlSubscriptionCallbacks<TResult>,
|
||||
expectedRuntimeId?: string
|
||||
callbacks: SharedControlSubscriptionCallbacks<TResult>
|
||||
): Promise<RemoteRuntimeSharedSubscription> {
|
||||
return startSharedControlSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
method,
|
||||
params,
|
||||
expectedRuntimeId,
|
||||
callbacks,
|
||||
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
|
||||
sendSubscription: (subscription) => this.sendSubscription(subscription),
|
||||
@@ -182,7 +182,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
onError: (error) => this.handleSocketClosed(error, socketGeneration),
|
||||
onTextFrame: (frame) => this.handleTextFrame(frame, socketGeneration),
|
||||
liveness: {
|
||||
options: this.opts.liveness,
|
||||
options: this.options.liveness,
|
||||
onDead: (error) => this.handleSocketClosed(error, socketGeneration)
|
||||
}
|
||||
})
|
||||
@@ -204,7 +204,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
frame,
|
||||
state: this.state,
|
||||
sharedKey: this.sharedKey,
|
||||
environmentId: this.opts.environmentId,
|
||||
environmentId: this.options.environmentId,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
pendingRequests: this.pendingRequests,
|
||||
subscriptions: this.subscriptions,
|
||||
@@ -217,7 +217,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
sendEncrypted: (payload) => this.sendEncrypted(payload),
|
||||
markReady: () => {
|
||||
this.lastConnectedAt = Date.now()
|
||||
this.stableReset.schedule({
|
||||
this.readyStableReset.schedule({
|
||||
getState: () => this.state,
|
||||
getSocket: () => this.ws,
|
||||
reset: () => this.reconnect.resetAttempt()
|
||||
@@ -301,7 +301,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
|
||||
private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void {
|
||||
closeSharedControlSocket({
|
||||
environmentId: this.opts.environmentId,
|
||||
environmentId: this.options.environmentId,
|
||||
state: this.state,
|
||||
pendingRequests: this.pendingRequests,
|
||||
subscriptions: this.subscriptions,
|
||||
@@ -311,7 +311,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
ws: this.ws,
|
||||
error,
|
||||
preserveReadyWaitersAndPendingRequests,
|
||||
clearReadyStableTimer: () => this.stableReset.clear()
|
||||
clearReadyStableTimer: () => this.readyStableReset.clear()
|
||||
})
|
||||
this.ws = this.sharedKey = null
|
||||
this.socketCleanup = null
|
||||
|
||||
@@ -36,8 +36,7 @@ export function sendSharedControlSubscription(args: {
|
||||
id: args.subscription.requestId,
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.subscription.method,
|
||||
params: args.subscription.params,
|
||||
expectedRuntimeId: args.subscription.expectedRuntimeId
|
||||
params: args.subscription.params
|
||||
})
|
||||
) {
|
||||
args.subscription.sent = true
|
||||
|
||||
@@ -14,7 +14,6 @@ export async function startSharedControlSubscription<TResult>(args: {
|
||||
deviceToken: string
|
||||
method: string
|
||||
params: unknown
|
||||
expectedRuntimeId?: string
|
||||
callbacks: SharedControlSubscriptionCallbacks<TResult>
|
||||
ensureReady: () => Promise<void>
|
||||
sendSubscription: (subscription: SharedControlLogicalSubscription<unknown>) => void
|
||||
@@ -24,15 +23,13 @@ export async function startSharedControlSubscription<TResult>(args: {
|
||||
subscriptions: args.subscriptions,
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
expectedRuntimeId: args.expectedRuntimeId
|
||||
params: args.params
|
||||
})
|
||||
const requestId = randomUUID()
|
||||
const subscription = createSharedControlSubscription({
|
||||
requestId,
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
expectedRuntimeId: args.expectedRuntimeId,
|
||||
retainedParamsBytes,
|
||||
callbacks: args.callbacks
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
|
||||
import { sendSharedControlSubscription } from './remote-runtime-shared-control-send'
|
||||
import {
|
||||
closeSharedControlLogicalSubscription,
|
||||
createSharedControlSubscription,
|
||||
@@ -34,39 +33,6 @@ function okResponse(subscriptionId: string): RuntimeRpcResponse<unknown> {
|
||||
}
|
||||
|
||||
describe('closeSharedControlLogicalSubscription — replay-window leak', () => {
|
||||
it('preserves the runtime fence when reconnect replay resends a subscription', () => {
|
||||
const subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
const subscription = createSharedControlSubscription({
|
||||
requestId: 'req-fenced',
|
||||
method: 'runtime.clientEvents.subscribe',
|
||||
params: null,
|
||||
expectedRuntimeId: 'runtime-1',
|
||||
retainedParamsBytes: 0,
|
||||
callbacks: { onResponse: vi.fn(), onError: vi.fn() }
|
||||
})
|
||||
subscriptions.set(subscription.requestId, subscription)
|
||||
const payloads: unknown[] = []
|
||||
const send = (current: SharedControlLogicalSubscription<unknown>): void =>
|
||||
sendSharedControlSubscription({
|
||||
subscriptions,
|
||||
subscription: current,
|
||||
deviceToken: 'device-token',
|
||||
send: (payload) => {
|
||||
payloads.push(payload)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
send(subscription)
|
||||
replaySharedControlSubscriptions({ subscriptions, send })
|
||||
|
||||
expect(payloads).toHaveLength(2)
|
||||
expect(payloads).toEqual([
|
||||
expect.objectContaining({ expectedRuntimeId: 'runtime-1' }),
|
||||
expect.objectContaining({ expectedRuntimeId: 'runtime-1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the unsubscribe when closed after an established subscribe replay completes', () => {
|
||||
const { subscriptions, subscription } = makeSubscriptions()
|
||||
// First establishment: server assigned a concrete subscription id.
|
||||
|
||||
@@ -14,7 +14,6 @@ export function createSharedControlSubscription<TResult>(args: {
|
||||
requestId: string
|
||||
method: string
|
||||
params: unknown
|
||||
expectedRuntimeId?: string
|
||||
retainedParamsBytes: number
|
||||
callbacks: SharedControlSubscriptionCallbacks<TResult>
|
||||
}): SharedControlLogicalSubscription<TResult> {
|
||||
@@ -22,7 +21,6 @@ export function createSharedControlSubscription<TResult>(args: {
|
||||
requestId: args.requestId,
|
||||
method: args.method,
|
||||
params: args.params,
|
||||
expectedRuntimeId: args.expectedRuntimeId,
|
||||
retainedParamsBytes: args.retainedParamsBytes,
|
||||
callbacks: args.callbacks,
|
||||
sent: false,
|
||||
|
||||
@@ -33,7 +33,6 @@ export type SharedControlLogicalSubscription<TResult = unknown> = {
|
||||
requestId: string
|
||||
method: string
|
||||
params: unknown
|
||||
expectedRuntimeId?: string
|
||||
retainedParamsBytes: number
|
||||
callbacks: SharedControlSubscriptionCallbacks<TResult>
|
||||
sent: boolean
|
||||
|
||||
@@ -76,8 +76,6 @@ export type RuntimeRpcFailure = {
|
||||
export type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
|
||||
|
||||
export type RuntimeOrchestrationEnvelope = {
|
||||
/** Reject before dispatch when the request reaches a replacement runtime. */
|
||||
expectedRuntimeId?: string
|
||||
orchestrationCapability?: string
|
||||
orchestrationContractVersion?: number
|
||||
orchestrationRequestId?: string
|
||||
|
||||
Reference in New Issue
Block a user