fix terminal attribution shim removal edge cases (#14187)

* fix(terminal): fully retire attribution shim

* fix(terminal): harden shim tombstone path lookup
This commit is contained in:
Neil
2026-08-12 23:22:48 -07:00
committed by GitHub
parent 602f0cbe63
commit 585dd6d3a9
79 changed files with 3001 additions and 566 deletions
+40 -17
View File
@@ -127,6 +127,15 @@ 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 {
@@ -3650,20 +3659,27 @@ export default function SessionScreen() {
.slice(2, 10)}`
try {
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'
})
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 }
)
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
const created = result.tab
@@ -3763,10 +3779,17 @@ export default function SessionScreen() {
showToast(message, 1800)
}
}
} catch {
const message = options?.errorToast ?? 'Failed to create terminal'
} 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')
setCreateError(message)
if (options?.errorToast) {
if (
options?.errorToast ||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
) {
triggerError()
showToast(message, 1800)
}
@@ -226,6 +226,13 @@ 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' } }
@@ -249,13 +256,20 @@ 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(
1,
2,
'session.tabs.createTerminal',
{
worktree: 'id:worktree-1',
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
env: {
ANTHROPIC_BASE_URL: 'http://localhost:3000',
ORCA_ATTRIBUTION_BYPASS: '1'
},
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
@@ -269,10 +283,14 @@ 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 }
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: 'runtime'
}
)
expect(sendRequest).toHaveBeenNthCalledWith(
2,
3,
'terminal.send',
{
terminal: 'pty-1',
@@ -284,17 +302,35 @@ describe('resumeAiVaultSessionInTerminal', () => {
})
it('throws when terminal creation fails', async () => {
const sendRequest = vi.fn().mockResolvedValueOnce({
ok: false,
error: { message: 'no terminal' }
})
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.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: { tab: { id: 'x' } } })
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
await expect(
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
).rejects.toThrow('Created terminal response was invalid')
@@ -303,6 +339,13 @@ 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' } }
@@ -316,6 +359,13 @@ 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' } }
@@ -327,6 +377,17 @@ 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', () => {
+13 -3
View File
@@ -16,6 +16,10 @@ 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,
@@ -23,6 +27,7 @@ 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',
@@ -169,12 +174,13 @@ 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}`,
...(launch.env ? { env: launch.env } : {}),
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
env: withLegacyTerminalAttributionDisabledEnv(launch.env),
envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete),
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
@@ -182,7 +188,11 @@ export async function resumeAiVaultSessionInTerminal(
select: true,
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: authority.runtimeId
}
)
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
@@ -53,6 +53,14 @@ 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' } }
@@ -64,9 +72,12 @@ describe('prepareMobileAiVaultSessionResume', () => {
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
expect(prepared).toBe(legacy)
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
})
expect(sendRequest).toHaveBeenNthCalledWith(
3,
4,
'terminal.send',
{
terminal: 'pty-1',
@@ -0,0 +1,81 @@
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'
)
})
})
@@ -0,0 +1,34 @@
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 })
}
+41 -13
View File
@@ -12,6 +12,10 @@ 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])
@@ -20,16 +24,27 @@ function clientReturning(...responses: RpcResponse[]) {
describe('createTerminalAndSendPrompt', () => {
it('creates a terminal then sends the prompt with enter', async () => {
const client = clientReturning(createdTerminal, sendAccepted)
const client = clientReturning(safeHost, createdTerminal, sendAccepted)
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
worktree: 'id:wt-1',
activate: false,
select: true,
navigation: 'caller'
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
})
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
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', {
terminal: 'term-1',
text: 'do the thing',
enter: true
@@ -37,28 +52,41 @@ describe('createTerminalAndSendPrompt', () => {
})
it('throws and skips terminal.send when createTerminal fails', async () => {
const client = clientReturning(failure('boom'))
const client = clientReturning(safeHost, failure('boom'))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom')
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('throws when the created-terminal response is malformed', async () => {
const client = clientReturning(success({ tab: { type: 'terminal' } }))
const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } }))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
'Created terminal response was invalid'
)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('throws when terminal.send returns a failure', async () => {
const client = clientReturning(createdTerminal, failure('send failed'))
const client = clientReturning(safeHost, 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(createdTerminal, success({ send: { accepted: false } }))
const client = clientReturning(
safeHost,
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)
})
})
+21 -6
View File
@@ -3,6 +3,14 @@ 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 —
@@ -15,12 +23,19 @@ export async function createTerminalAndSendPrompt(
worktreeId: string,
prompt: string
): Promise<void> {
const created = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
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 }
)
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
}
@@ -163,6 +163,74 @@ 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,6 +13,14 @@ 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
@@ -104,12 +112,19 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
if (!client || connState !== 'connected') {
throw new Error('Waiting for desktop...')
}
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
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 }
)
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to create terminal')
}
+10 -1
View File
@@ -54,6 +54,7 @@ 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`
@@ -1013,7 +1014,15 @@ export function connect(
}
})
if (!sendEncrypted({ id, deviceToken, method, params })) {
if (
!sendEncrypted({
id,
deviceToken,
method,
params,
...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {})
})
) {
pending.delete(id)
clearTimeout(timeout)
reject(new Error('Connection interrupted'))
+1
View File
@@ -18,6 +18,7 @@ export type RpcRequest = {
deviceToken: string
method: string
params?: unknown
expectedRuntimeId?: string
}
export type RpcSuccess = {
+64
View File
@@ -61,3 +61,67 @@ 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())
})
})
+52 -17
View File
@@ -8,7 +8,8 @@ import type {
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
RuntimeTerminalWait,
RuntimeStatus
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
@@ -37,6 +38,14 @@ 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
@@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const useRendererBackedInteractiveTerminal =
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
const focus = flags.get('focus') === true
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 } : {})
})
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 }
)
printResult(result, json, formatTerminalCreate)
},
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
@@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
) {
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
}
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command')
})
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 }
)
printResult(result, json, formatTerminalSplit)
}
}
+202 -82
View File
@@ -3112,6 +3112,10 @@ 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',
@@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true,
presentation: 'focused'
})
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' }
)
})
it('prints terminal.read fallback screen lines in json mode', async () => {
@@ -3184,6 +3194,10 @@ 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',
@@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
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' }
)
})
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',
@@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
})
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' }
)
})
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',
@@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
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' }
)
})
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',
@@ -3323,17 +3367,27 @@ 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',
title: 'Codex exec',
focus: false
})
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' }
)
})
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',
@@ -3359,17 +3413,27 @@ 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',
title: 'Codex review',
focus: false
})
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' }
)
})
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',
@@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
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' }
)
})
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',
@@ -3431,19 +3505,29 @@ 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"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
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' }
)
})
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',
@@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
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' }
)
})
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',
@@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
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' }
)
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
@@ -3819,6 +3919,10 @@ 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',
@@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
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' }
)
})
it('collects and formats memory diagnostics', async () => {
@@ -3955,6 +4065,10 @@ 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',
@@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
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' }
)
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
+1
View File
@@ -87,6 +87,7 @@ export class RuntimeClient {
}
: {}
const envelope = {
expectedRuntimeId: options?.expectedRuntimeId,
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
+42
View File
@@ -50,6 +50,48 @@ 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')
+1
View File
@@ -186,6 +186,7 @@ export async function sendRequest<TResult>(
authToken: metadata.authToken,
method,
params,
expectedRuntimeId: envelope?.expectedRuntimeId,
orchestrationCapability: envelope?.orchestrationCapability,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId,
+39 -2
View File
@@ -78,6 +78,10 @@ vi.mock('../providers/windows-conpty-process-membership', () => ({
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
import {
LEGACY_TERMINAL_SHIM_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_OPENCODE_CONFIG_DIR',
@@ -1275,6 +1279,35 @@ describe('createPtySubprocess', () => {
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined()
})
it('does not inherit legacy attribution state from a pre-upgrade daemon', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const saved = Object.fromEntries(
[...LEGACY_TERMINAL_SHIM_ENV_KEYS, 'PATH'].map((key) => [key, process.env[key]])
)
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
process.env.PATH = `/tmp/orca-terminal-attribution/posix${delimiter}/usr/bin`
try {
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.PATH).toBe('/usr/bin')
for (const key of LEGACY_TERMINAL_SHIM_ENV_KEYS) {
expect(env[key]).toBeUndefined()
}
})
it('does not inherit NODE_ENV from the daemon process env', () => {
// Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking
// Orca's build mode into user shells breaks `next build` and Vitest.
@@ -1295,7 +1328,9 @@ describe('createPtySubprocess', () => {
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.NODE_ENV).toBeUndefined()
expect(env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => {
@@ -2207,6 +2242,8 @@ describe('createPtySubprocess', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, 'win32')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
@@ -2218,7 +2255,7 @@ describe('createPtySubprocess', () => {
}
const env = spawnMock.mock.calls.at(-1)![2].env
expect(env.PATH).toBe(process.env.PATH)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('preserves a duplicated path block supplied by main', () => {
+5
View File
@@ -35,6 +35,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
@@ -629,6 +630,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links.
FORCE_HYPERLINK: '1'
} as Record<string, string>
// Why: an older client may not ask a newly upgraded daemon to delete inherited shim state.
stripLegacyTerminalShimEnv(env, process.platform)
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
@@ -834,6 +837,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
: undefined
promoteAgentTeamsShimPath(env, requestedPath)
// Why: raw requested PATH promotion runs after the inherited-env scrub.
stripLegacyTerminalShimEnv(env, process.platform)
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
ensureNodePtySpawnHelperExecutable()
+2 -2
View File
@@ -185,7 +185,7 @@ import {
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import {
createServeDesktopActivationGate,
@@ -2172,7 +2172,7 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
removeLegacyTerminalShimDir(app.getPath('userData'))
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
const windowsShellPathHydration = createWindowsShellPathHydration()
configureWindowsHostGitEnvironmentReadiness(
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
+43
View File
@@ -248,6 +248,7 @@ import {
type PrepareCodexSessionResume
} from './pty'
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell'
import {
@@ -4399,6 +4400,16 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({})
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -4436,6 +4447,38 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env for runtime PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => {
// Why: the runtime controller is the `orca` CLI / automation spawn path and
// assembles envToDelete separately from the renderer's pty:spawn handler;
+5
View File
@@ -113,6 +113,7 @@ import {
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import {
isLegacyTerminalShimPathEntry,
LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
import { registerPty, unregisterPty } from '../memory/pty-registry'
@@ -4715,6 +4716,8 @@ export function registerPtyHandlers(
spawnOptions.envToDelete = mergePtyEnvDeletions(
authEnvToDelete,
args.envToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [],
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
getInheritedClaudeSessionStampEnvKeysToDelete(env)
@@ -6354,6 +6357,8 @@ export function registerPtyHandlers(
envToDelete,
args.envToDelete ?? [],
agentTeamsEnvToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [],
getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
@@ -183,6 +183,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}
): Promise<RuntimeRpcResponse<unknown>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
@@ -197,7 +198,8 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
args.method,
args.params,
args.timeoutMs,
args.expectedEnvironmentPairingRevision
args.expectedEnvironmentPairingRevision,
args.expectedRuntimeId ? { expectedRuntimeId: args.expectedRuntimeId } : undefined
)
} catch (error) {
const failure = runtimeEnvironmentCallFailure(environment, args.method, error)
@@ -77,13 +77,15 @@ 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
callbacks,
envelope?.expectedRuntimeId
)
}
@@ -193,11 +193,14 @@ 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) {
@@ -240,7 +243,8 @@ export async function subscribeRuntimeEnvironment(
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
callbacksWithMarkUsed,
envelope
)
}
return await subscribeRemoteRuntimeRequest(
@@ -248,7 +252,9 @@ export async function subscribeRuntimeEnvironment(
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
callbacksWithMarkUsed,
undefined,
envelope
)
} catch (error) {
if (error instanceof Error) {
+39 -9
View File
@@ -1094,7 +1094,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
await add(null, { name: 'desk', pairingCode: pairingCode() })
const subscribe = handler<
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
{
selector: string
method: string
params?: unknown
subscriptionId?: string
expectedRuntimeId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
await subscribe(
@@ -1107,7 +1113,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
removeListener: vi.fn()
}
},
{ selector: 'desk', method: 'browser.screencast', params: { pageId: 'page-1' } }
{
selector: 'desk',
method: 'browser.screencast',
params: { pageId: 'page-1' },
expectedRuntimeId: 'runtime-1'
}
)
await subscribe(
{
@@ -1127,14 +1138,18 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'browser.screencast',
{ pageId: 'page-1' },
15_000,
expect.any(Object)
expect.any(Object),
undefined,
{ expectedRuntimeId: 'runtime-1' }
)
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.any(Object),
'terminal.multiplex',
{ client: { id: 'client-1' } },
15_000,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
})
@@ -1163,7 +1178,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
await add(null, { name: 'desk', pairingCode: pairingCode() })
const subscribe = handler<
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
{
selector: string
method: string
params?: unknown
subscriptionId?: string
expectedRuntimeId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
await expect(
@@ -1177,7 +1198,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
removeListener: vi.fn()
}
},
{ selector: 'desk', method: 'session.tabs.subscribeAll' }
{
selector: 'desk',
method: 'session.tabs.subscribeAll',
expectedRuntimeId: 'runtime-1'
}
)
).resolves.toMatchObject({ requestId: 'tabs-shared' })
@@ -1187,7 +1212,8 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'session.tabs.subscribeAll',
undefined,
15_000,
expect.any(Object)
expect.any(Object),
{ expectedRuntimeId: 'runtime-1' }
)
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
})
@@ -1315,7 +1341,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'session.tabs.subscribeAll',
undefined,
15_000,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
})
@@ -1659,7 +1687,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'terminal.subscribe',
{ terminal: 't1' },
25,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(sent).toEqual([
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
+3 -1
View File
@@ -90,6 +90,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
timeoutMs?: number
subscriptionId?: string
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}
): Promise<{ subscriptionId: string; requestId: string }> => {
const subscriptionId =
@@ -180,7 +181,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
retained?.removeDestroyedListener()
remoteRuntimeSubscriptions.delete(subscriptionId)
}
}
},
args.expectedRuntimeId
)
} catch (error) {
removeDestroyedListener()
+44
View File
@@ -5659,6 +5659,50 @@ describe('Store', () => {
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
})
it('retires the persisted GitHub attribution setting without dropping unknown settings', async () => {
const settledStore = await createStore()
settledStore.flush()
const settled = readDataFile() as { settings: Record<string, unknown> }
settled.settings.enableGitHubAttribution = true
settled.settings.futureSetting = { enabled: true }
writeDataFile(settled)
vi.useFakeTimers()
try {
const store = await createStore()
expect(store.getSettings()).not.toHaveProperty('enableGitHubAttribution')
expect(store.getSettings()).toHaveProperty('futureSetting', { enabled: true })
vi.advanceTimersByTime(5_000)
await store.waitForPendingWrite()
} finally {
vi.useRealTimers()
}
const persisted = readDataFile() as { settings?: Record<string, unknown> }
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
expect(persisted.settings).toHaveProperty('futureSetting', { enabled: true })
})
it('ignores retired GitHub attribution updates and strips stale in-memory values on save', async () => {
const store = await createStore()
const listener = vi.fn()
store.onSettingsChanged(listener)
const updated = store.updateSettings({ enableGitHubAttribution: true } as never, {
notifyListeners: true
})
expect(updated).not.toHaveProperty('enableGitHubAttribution')
expect(listener).not.toHaveBeenCalled()
const settings = store.getSettings() as GlobalSettings & Record<string, unknown>
settings.enableGitHubAttribution = false
settings.futureSetting = 'kept'
store.flush()
const persisted = readDataFile() as { settings?: Record<string, unknown> }
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
expect(persisted.settings?.futureSetting).toBe('kept')
})
it('normalizes terminal cursor style before persistence and listener broadcasts', async () => {
const store = await createStore()
store.updateSettings({ terminalCursorStyle: 'underline' })
+22 -6
View File
@@ -651,6 +651,11 @@ type LegacyTerminalScrollbackSettings = {
terminalScrollbackBytes?: unknown
}
type RetiredGlobalSettings = {
terminalScrollbackBytes?: unknown
enableGitHubAttribution?: unknown
}
const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3
function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings {
@@ -659,12 +664,16 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal
: {}
}
function stripLegacyTerminalScrollbackBytes(
function stripRetiredGlobalSettings(
settings: Partial<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { terminalScrollbackBytes?: unknown }
const {
terminalScrollbackBytes: _legacyScrollbackBytes,
enableGitHubAttribution: _legacyGitHubAttribution,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
void _legacyScrollbackBytes
void _legacyGitHubAttribution
return rest
}
@@ -3149,6 +3158,13 @@ export class Store {
if (migratedTerminalScrollback.needsSave) {
this.loadNeedsSave = true
}
if (
parsed.settings &&
typeof parsed.settings === 'object' &&
Object.hasOwn(parsed.settings, 'enableGitHubAttribution')
) {
this.loadNeedsSave = true
}
const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault(
parsed.settings
)
@@ -3421,7 +3437,7 @@ export class Store {
settings: {
...defaults.settings,
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
...stripLegacyTerminalScrollbackBytes(parsed.settings),
...stripRetiredGlobalSettings(parsed.settings),
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
parsed.settings?.prBotAuthorOverrides
),
@@ -4013,7 +4029,7 @@ export class Store {
)
})),
settings: {
...this.state.settings,
...stripRetiredGlobalSettings(this.state.settings),
opencodeSessionCookie: encryptToSentinel(
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
this.state.settings.opencodeSessionCookie
@@ -5819,7 +5835,7 @@ export class Store {
updates: Partial<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
const sanitizedUpdates = stripRetiredGlobalSettings(updates)
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
}
+17 -1
View File
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as MacosTccLoginShell from './macos-tcc-login-shell'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
const {
existsSyncMock,
@@ -576,7 +577,9 @@ describe('LocalPtyProvider', () => {
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.NODE_ENV).toBeUndefined()
expect(spawnCall[2].env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => {
@@ -752,6 +755,19 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('does not re-promote a legacy attribution path for Agent Teams', async () => {
await provider.spawn({
cols: 80,
rows: 24,
env: {
PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
}
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin')
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
const keys = [
'GIT_CONFIG_COUNT',
+3
View File
@@ -36,6 +36,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
@@ -829,6 +830,8 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv,
requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined
)
// Why: raw requested PATH promotion runs after the host-env scrub.
stripLegacyTerminalShimEnv(finalEnv, process.platform)
// Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10).
const worktreeId = args.worktreeId
@@ -62,6 +62,7 @@ import {
unregisterSshPtyProvider
} from '../ipc/pty'
import type { IPtyProvider } from './types'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
describe('PTY provider dispatch', () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
@@ -147,6 +148,7 @@ describe('PTY provider dispatch', () => {
const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0]
expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual(
[
...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_BRIDGE_SESSION_ID'
@@ -0,0 +1,69 @@
const SHELL_DOLLAR = '$'
const POSIX_TOMBSTONE = String.raw`#!/usr/bin/env bash
set -u
command_name="__ORCA_COMMAND__"
wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)"
legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}"
cleaned_path="${SHELL_DOLLAR}{PATH:-}"
filter_path() {
local legacy_target="$legacy_wrapper_dir"
while [[ "$legacy_target" != "/" && "$legacy_target" == */ ]]; do
legacy_target="${SHELL_DOLLAR}{legacy_target%/}"
done
local remaining="$cleaned_path"
local filtered_path=""
local separator=""
path_entry_kept=0
local entry normalized candidate has_more
while true; do
if [[ "$remaining" == *:* ]]; then
entry="${SHELL_DOLLAR}{remaining%%:*}"
remaining="${SHELL_DOLLAR}{remaining#*:}"
has_more=1
else
entry="$remaining"
has_more=0
fi
normalized="$entry"
while [[ "$normalized" != "/" && "$normalized" == */ ]]; do
normalized="${SHELL_DOLLAR}{normalized%/}"
done
candidate="${SHELL_DOLLAR}{entry:-.}"
if [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then
:
elif [[ "$candidate" -ef "$wrapper_dir" ]]; then
:
else
filtered_path+="$separator$entry"
separator=":"
path_entry_kept=1
fi
[[ "$has_more" == 1 ]] || break
done
cleaned_path="$filtered_path"
}
filter_path
unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER
unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS
real_command=""
if [[ "$path_entry_kept" == 1 ]]; then
real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)"
fi
if [[ -n "$real_command" && "$real_command" -ef "${SHELL_DOLLAR}{BASH_SOURCE[0]}" ]]; then
real_command=""
fi
if [[ -z "$real_command" ]]; then
printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2
exit 127
fi
PATH="$cleaned_path" exec "$real_command" "$@"
`
export function renderLegacyTerminalPosixTombstone(command: 'git' | 'gh'): string {
return POSIX_TOMBSTONE.replaceAll('__ORCA_COMMAND__', command)
}
+277 -58
View File
@@ -1,64 +1,209 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__resetLegacyTerminalShimRemovalForTests,
removeLegacyTerminalShimDir,
__resetLegacyTerminalShimNeutralizationForTests,
neutralizeLegacyTerminalShimDir,
stripLegacyTerminalShimEnv
} from './legacy-terminal-shim-dir'
// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses.
const itOnPosix = process.platform === 'win32' ? it.skip : it
// Why: the failure case uses directory permissions, which Windows ignores and root bypasses.
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
describe('legacy terminal shim removal', () => {
describe('legacy terminal shim neutralization', () => {
const tempRoots: string[] = []
const makeUserDataDir = (): string => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
tempRoots.push(userData)
return userData
}
beforeEach(() => {
__resetLegacyTerminalShimRemovalForTests()
__resetLegacyTerminalShimNeutralizationForTests()
})
it('deletes the orphaned wrapper directory left by older installs', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
mkdirSync(shimDir, { recursive: true })
writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n')
removeLegacyTerminalShimDir(userData)
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
expect(existsSync(userData)).toBe(true)
afterEach(() => {
vi.useRealTimers()
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true })
}
})
it('does not throw when nothing is left to remove', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow()
})
itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
it('atomically replaces the legacy command paths with executable tombstones', () => {
const userData = makeUserDataDir()
const legacyRoot = join(userData, 'orca-terminal-attribution')
mkdirSync(join(legacyRoot, 'posix'), { recursive: true })
writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n')
// Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does.
chmodSync(userData, 0o500)
const posixDir = join(legacyRoot, 'posix')
const win32Dir = join(legacyRoot, 'win32')
mkdirSync(posixDir, { recursive: true })
mkdirSync(win32Dir, { recursive: true })
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
for (const path of [
join(posixDir, 'git'),
join(posixDir, 'gh'),
join(win32Dir, 'git.cmd'),
join(win32Dir, 'gh.cmd')
]) {
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca')
if (process.platform !== 'win32') {
expect(statSync(path).mode & 0o111).not.toBe(0)
}
}
expect(readFileSync(join(legacyRoot, 'VERSION'), 'utf8')).toBe('7\n')
})
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
const userData = makeUserDataDir()
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
neutralizeLegacyTerminalShimDir(userData)
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
expect(cmd).toContain(
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
)
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
expect(powershell).toContain('$realCommand = $null')
expect(powershell.indexOf('[StringComparison]::OrdinalIgnoreCase')).toBeLessThan(
powershell.indexOf('Test-Path -LiteralPath $realCommand')
)
})
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
const userData = makeUserDataDir()
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
neutralizeLegacyTerminalShimDir(userData)
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
expect(cmd.indexOf(cmdCapture)).toBeLessThan(cmd.indexOf('set "ORCA_ATTRIBUTION_SHIM_DIR="'))
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"')
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
expect(cmd).toContain(
'if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\\" exit /b'
)
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
expect(powershell.indexOf('$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR')).toBeLessThan(
powershell.indexOf('Remove-Item "Env:$_"')
)
expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)')
expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {")
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
})
it('does not throw when the legacy directory is absent', () => {
const userData = makeUserDataDir()
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true)
})
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
vi.useFakeTimers()
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const gitWrapper = join(posixDir, 'git')
mkdirSync(posixDir, { recursive: true })
writeFileSync(gitWrapper, 'legacy attribution wrapper')
chmodSync(posixDir, 0o500)
try {
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper')
} finally {
chmodSync(userData, 0o700)
chmodSync(posixDir, 0o700)
}
// The failed attempt must not have latched the guard.
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(false)
try {
await vi.advanceTimersByTimeAsync(1_000)
expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper')
// A success latches it: a directory recreated afterwards is left alone.
mkdirSync(legacyRoot, { recursive: true })
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
writeFileSync(gitWrapper, 'recreated after success')
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success')
} finally {
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')
const shimGit = join(shimDir, 'git')
const realBin = join(userData, 'real-bin')
const realGit = join(realBin, 'git')
mkdirSync(shimDir, { recursive: true })
mkdirSync(realBin, { recursive: true })
writeFileSync(shimGit, '#!/usr/bin/env bash\nexit 99\n', { mode: 0o755 })
writeFileSync(
realGit,
"#!/usr/bin/env bash\nprintf 'arg=<%s>\\n' \"$@\"\ncat\nprintf 'fixture stderr\\n' >&2\nexit 23\n",
{ mode: 0o755 }
)
const child = spawn('bash', ['--noprofile', '--norc'], {
cwd: shimDir,
env: {
...process.env,
PATH: `${shimDir}//::${realBin}:${process.env.PATH ?? ''}`,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
ORCA_ATTRIBUTION_SHIM_DIR: ''
},
stdio: ['pipe', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
})
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const ready = waitForOutput(child.stdout, '__ORCA_HASH_READY__\n')
child.stdin.write(`hash -p ${quoteBash(shimGit)} git\nprintf '__ORCA_HASH_READY__\\n'\n`)
try {
await ready
} catch (error) {
child.kill('SIGKILL')
throw error
}
neutralizeLegacyTerminalShimDir(userData)
const closed = waitForChildClose(child, 2_000)
child.stdin.end("printf 'stdin payload\\n' | git commit -m 'subject with spaces'; exit $?\n")
try {
expect(await closed).toBe(23)
} finally {
child.kill('SIGKILL')
}
expect(stdout).toContain('arg=<commit>\narg=<-m>\narg=<subject with spaces>\nstdin payload\n')
expect(stdout).not.toContain('Co-authored-by: Orca')
expect(stderr).toBe('fixture stderr\n')
})
it('drops inherited shim env and its PATH entry without touching real entries', () => {
// Why: a daemon predating the removal reseeds these from its own process.env.
const env: Record<string, string> = {
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
@@ -73,20 +218,50 @@ describe('legacy terminal shim removal', () => {
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(env.ORCA_REAL_GIT).toBeUndefined()
expect(env.ORCA_REAL_GH).toBeUndefined()
expect(env.HOME).toBe('/home/u')
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
})
it('strips the Windows shim entry on the inherited Path spelling', () => {
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32`
PATH: `/usr/local/bin:${shimDir}:/usr/bin`,
ORCA_ATTRIBUTION_SHIM_DIR: shimDir
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' })
})
it('treats legacy Windows environment keys case-insensitively', () => {
const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32'
const env: Record<string, string> = {
Path: `${shimDir};C:\\Windows\\System32`,
orca_attribution_shim_dir: shimDir,
Orca_Enable_Git_Attribution: '1',
orca_real_git: 'C:\\Git\\git.exe'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env).toEqual({ Path: 'C:\\Windows\\System32' })
})
it('strips legacy entries from every Windows PATH spelling', () => {
const env: Record<string, string> = {
PATH: 'C:\\Orca\\orca-terminal-attribution\\win32',
Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.PATH).toBeUndefined()
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased Windows shim path', () => {
const env: Record<string, string> = {
Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
@@ -94,25 +269,26 @@ describe('legacy terminal shim removal', () => {
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased shim entry on case-insensitive filesystems', () => {
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32`
}
it('preserves explicit empty PATH values', () => {
const windowsEnv: Record<string, string> = { PATH: '', Path: 'C:\\Windows' }
const posixEnv: Record<string, string> = { PATH: '' }
stripLegacyTerminalShimEnv(env, 'win32')
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
stripLegacyTerminalShimEnv(posixEnv, 'linux')
expect(env.Path).toBe('C:\\Windows\\System32')
expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' })
expect(posixEnv).toEqual({ PATH: '' })
})
it('keeps neighbouring directories that merely share the name prefix', () => {
const env: Record<string, string> = {
PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe(
'/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
'/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
)
})
@@ -122,3 +298,46 @@ describe('legacy terminal shim removal', () => {
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
})
})
function quoteBash(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise<void> {
return new Promise((resolve, reject) => {
let output = ''
const onData = (chunk: string | Buffer): void => {
output += chunk.toString()
if (output.includes(marker)) {
cleanup()
resolve()
}
}
const onEnd = (): void => {
cleanup()
reject(new Error(`Bash exited before emitting ${marker}`))
}
const cleanup = (): void => {
stream.off('data', onData)
stream.off('end', onEnd)
}
stream.on('data', onData)
stream.on('end', onEnd)
})
}
function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs: number
): Promise<number | null> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Bash did not exit within ${timeoutMs}ms`))
}, timeoutMs)
child.once('close', (exitCode) => {
clearTimeout(timeout)
resolve(exitCode)
})
})
}
+236 -38
View File
@@ -1,71 +1,269 @@
import { rmSync } from 'node:fs'
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { resolvePathEnvKey } from './windows-environment-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_SHIM_ROOT_DIR = 'orca-terminal-attribution'
const LEGACY_SHIM_ENV_KEYS = [
const LEGACY_SHIM_VERSION = '7'
const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000]
export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [
'ORCA_ENABLE_GIT_ATTRIBUTION',
'ORCA_GIT_COMMIT_TRAILER',
'ORCA_GH_PR_FOOTER',
'ORCA_GH_ISSUE_FOOTER',
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_REAL_GIT',
'ORCA_REAL_GH'
'ORCA_REAL_GH',
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY
] as const
export const LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS = [
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
] as const
let removed = false
const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off
setlocal
set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%"
set "orca_wrapper_dir=%~dp0"
set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"
set "orca_clean_path="
for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"
set "PATH=%orca_clean_path%"
set "ORCA_ENABLE_GIT_ATTRIBUTION="
set "ORCA_GIT_COMMIT_TRAILER="
set "ORCA_GH_PR_FOOTER="
set "ORCA_GH_ISSUE_FOOTER="
set "ORCA_ATTRIBUTION_SHIM_DIR="
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
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
exit /b 127
)
:run
"%orca_real%" %*
exit /b %ERRORLEVEL%
/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env
* (pty-subprocess reads process.env as the authoritative base), and the wrappers they
* point at are gated only on inherited env. Deleting the scripts makes both inert. */
export function removeLegacyTerminalShimDir(userDataPath: string): void {
if (removed) {
:orca_append_path
for %%G in ("%~1") do set "orca_path_entry_dir=%%~fG\"
if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b
if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\" exit /b
if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%~1") else set "orca_clean_path=%~1"
exit /b
`
const POWERSHELL_PASSTHROUGH_WRAPPER = String.raw`$ErrorActionPreference = 'Stop'
$commandName = '__ORCA_COMMAND__'
$realCommand = [Environment]::GetEnvironmentVariable('ORCA_REAL___ORCA_UPPER_COMMAND__')
$wrapperDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR
$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }
$env:PATH = (($env:PATH -split ';') | Where-Object {
$pathEntry = $_
$pathEntry -and -not ($wrapperDirs | Where-Object {
[string]::Equals($_, $pathEntry.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
})
}) -join ';'
'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', 'ORCA_REAL_GH', 'ORCA_ATTRIBUTION_BYPASS' | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
if ($realCommand) {
try {
$capturedDir = Split-Path -Parent ([IO.Path]::GetFullPath($realCommand))
if ([string]::Equals($capturedDir.TrimEnd('\'), $wrapperDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) {
$realCommand = $null
}
} catch {
$realCommand = $null
}
}
if (-not $realCommand -or -not (Test-Path -LiteralPath $realCommand)) {
$resolved = Get-Command "$commandName.exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
$realCommand = if ($resolved) { $resolved.Source } else { $null }
}
if (-not $realCommand) {
[Console]::Error.WriteLine("Orca compatibility wrapper could not locate $commandName on PATH.")
exit 127
}
& $realCommand @args
exit $LASTEXITCODE
`
let neutralized = false
let neutralizationRetryTimer: ReturnType<typeof setTimeout> | null = null
let neutralizationRetryAttempt = 0
export function neutralizeLegacyTerminalShimDir(userDataPath: string): void {
if (neutralized) {
return
}
try {
// Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like
// the other userData removals rather than forfeiting cleanup for the whole run.
rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50
})
removed = true
const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR)
writeNeutralWrappers(rootDir)
writeFileAtomically(join(rootDir, 'VERSION'), `${LEGACY_SHIM_VERSION}\n`, 0o644)
neutralized = true
clearNeutralizationRetry()
} catch {
// Best-effort: a locked file must not block startup, and the next launch retries.
// Why: Windows can keep a running .cmd open briefly after startup.
scheduleNeutralizationRetry(userDataPath)
}
}
function scheduleNeutralizationRetry(userDataPath: string): void {
if (
neutralizationRetryTimer ||
neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length
) {
return
}
const delayMs = NEUTRALIZATION_RETRY_DELAYS_MS[neutralizationRetryAttempt]
neutralizationRetryAttempt += 1
neutralizationRetryTimer = setTimeout(() => {
neutralizationRetryTimer = null
neutralizeLegacyTerminalShimDir(userDataPath)
}, delayMs)
neutralizationRetryTimer.unref?.()
}
function clearNeutralizationRetry(): void {
if (neutralizationRetryTimer) {
clearTimeout(neutralizationRetryTimer)
neutralizationRetryTimer = null
}
neutralizationRetryAttempt = 0
}
function writeNeutralWrappers(rootDir: string): void {
const posixDir = join(rootDir, 'posix')
const win32Dir = join(rootDir, 'win32')
mkdirSync(posixDir, { recursive: true })
mkdirSync(win32Dir, { recursive: true })
for (const command of ['git', 'gh'] as const) {
const upperCommand = command.toUpperCase()
writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755)
writeFileAtomically(
join(win32Dir, `${command}.cmd`),
renderWindowsWrapper(WIN32_PASSTHROUGH_WRAPPER, command, upperCommand),
0o755
)
writeFileAtomically(
join(win32Dir, `${command}-wrapper.ps1`),
renderWindowsWrapper(POWERSHELL_PASSTHROUGH_WRAPPER, command, upperCommand),
0o755
)
}
}
function renderWindowsWrapper(template: string, command: string, upperCommand: string): string {
return template
.replaceAll('__ORCA_UPPER_COMMAND__', upperCommand)
.replaceAll('__ORCA_COMMAND__', command)
}
function writeFileAtomically(filePath: string, contents: string, mode: number): void {
const temporaryPath = `${filePath}.orca-neutralizing-${process.pid}`
try {
rmSync(temporaryPath, { force: true, recursive: true })
writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode })
chmodSync(temporaryPath, mode)
renameSync(temporaryPath, filePath)
} finally {
rmSync(temporaryPath, { force: true, recursive: true })
}
}
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`)
const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
return (
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) ||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`)
)
}
export function stripLegacyTerminalShimEnv(
env: Record<string, string>,
platform: NodeJS.Platform = process.platform
): void {
for (const key of LEGACY_SHIM_ENV_KEYS) {
delete env[key]
const windows = platform === 'win32'
const legacyKeySet = new Set(
LEGACY_TERMINAL_SHIM_ENV_KEYS.map((key) => (windows ? key.toLowerCase() : key))
)
const shimDirKey = 'ORCA_ATTRIBUTION_SHIM_DIR'.toLowerCase()
const explicitShimDirs = Object.entries(env)
.filter(([key]) =>
windows ? key.toLowerCase() === shimDirKey : key === 'ORCA_ATTRIBUTION_SHIM_DIR'
)
.map(([, value]) => value)
.filter(Boolean)
for (const key of Object.keys(env)) {
if (windows ? legacyKeySet.has(key.toLowerCase()) : legacyKeySet.has(key)) {
delete env[key]
}
}
const pathKey = resolvePathEnvKey(env, platform)
const current = env[pathKey]
if (!current) {
return
const delimiter = windows ? ';' : ':'
const pathKeys = windows
? Object.keys(env).filter((key) => key.toLowerCase() === 'path')
: ['PATH']
for (const pathKey of pathKeys) {
const current = env[pathKey]
if (!current) {
continue
}
const withoutExplicitDirs = explicitShimDirs.reduce(
(pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows),
current
)
const cleaned = withoutExplicitDirs
.split(delimiter)
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
.join(delimiter)
if (cleaned) {
env[pathKey] = cleaned
} else {
delete env[pathKey]
}
}
const delimiter = platform === 'win32' ? ';' : ':'
const cleaned = current
.split(delimiter)
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
.join(delimiter)
if (cleaned) {
env[pathKey] = cleaned
} else {
delete env[pathKey]
}
function removeLiteralPathEntry(
pathValue: string,
entry: string,
delimiter: string,
caseInsensitive: boolean
): string {
let result = pathValue
const needle = caseInsensitive ? entry.toLowerCase() : entry
let searchStart = 0
for (;;) {
const comparable = caseInsensitive ? result.toLowerCase() : result
const index = comparable.indexOf(needle, searchStart)
if (index === -1) {
return result
}
const end = index + entry.length
const startsAtBoundary = index === 0 || result[index - 1] === delimiter
const endsAtBoundary = end === result.length || result[end] === delimiter
if (!startsAtBoundary || !endsAtBoundary) {
searchStart = index + 1
continue
}
if (result[end] === delimiter) {
result = result.slice(0, index) + result.slice(end + 1)
} else if (index > 0) {
result = result.slice(0, index - 1) + result.slice(end)
} else {
result = ''
}
searchStart = Math.max(0, index - 1)
}
}
/** Test-only: the module-level once-guard would otherwise leak across cases. */
export function __resetLegacyTerminalShimRemovalForTests(): void {
removed = false
export function __resetLegacyTerminalShimNeutralizationForTests(): void {
neutralized = false
clearNeutralizationRetry()
}
+1
View File
@@ -46,6 +46,7 @@ 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 } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, InvalidArgumentError, type RpcRequest } from './core'
@@ -41,6 +41,26 @@ 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 })
+21
View File
@@ -43,6 +43,14 @@ 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(
@@ -139,6 +147,19 @@ 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(
@@ -0,0 +1,37 @@
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
})
})
})
+2
View File
@@ -985,6 +985,7 @@ 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()
})
@@ -1472,6 +1473,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
direction: params.direction,
command: params.command,
env: params.env,
envToDelete: params.envToDelete,
telemetrySource: params.telemetrySource
})
})
+22
View File
@@ -231,6 +231,28 @@ 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({
+2
View File
@@ -3413,6 +3413,7 @@ export type PreloadApi = {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}) => Promise<RuntimeRpcResponse<unknown>>
subscribe: (
args: {
@@ -3421,6 +3422,7 @@ export type PreloadApi = {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
},
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void
+2
View File
@@ -4428,6 +4428,7 @@ const api = {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}): Promise<RuntimeRpcResponse<unknown>> =>
ipcRenderer.invoke('runtimeEnvironments:call', args),
subscribe: async (
@@ -4437,6 +4438,7 @@ const api = {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
},
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void
@@ -68,7 +68,12 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => {
const cleanupPromise = subscribeRuntimeEnvironmentFromPreload(
ipc,
{ selector: 'desk', method: 'terminal.subscribe' },
{
selector: 'desk',
method: 'terminal.subscribe',
expectedEnvironmentPairingRevision: 7,
expectedRuntimeId: 'runtime-1'
},
{ onResponse, onBinary },
() => 'sub-1'
)
@@ -80,6 +85,8 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => {
expect(ipc.invoke).toHaveBeenCalledWith('runtimeEnvironments:subscribe', {
selector: 'desk',
method: 'terminal.subscribe',
expectedEnvironmentPairingRevision: 7,
expectedRuntimeId: 'runtime-1',
subscriptionId: 'sub-1'
})
@@ -5,6 +5,8 @@ type RuntimeEnvironmentSubscribeArgs = {
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}
type RuntimeEnvironmentSubscriptionCallbacks = {
+51 -1
View File
@@ -11,6 +11,7 @@ import {
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -308,7 +309,56 @@ describe('PtyHandler', () => {
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnOptions.env.NODE_ENV).toBeUndefined()
expect(spawnOptions.env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH)
})
it('does not inherit legacy attribution state from the relay process', async () => {
const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
process.env.PATH = '/tmp/orca-terminal-attribution/posix:/usr/bin'
try {
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
const spawnedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as {
env: Record<string, string>
}
expect(spawnedEnv.env.PATH).toBe('/usr/bin')
expect(spawnedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(spawnedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
const state = (await dispatcher.callRequest('pty.serialize', {
ids: ['pty-1']
})) as string
await handler.dispose({ waitForPhysicalExit: false })
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as {
env: Record<string, string>
}
expect(revivedEnv.env.PATH).toBe('/usr/bin')
expect(revivedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
})
it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => {
+3
View File
@@ -47,6 +47,7 @@ import { isTuiAgent } from '../shared/tui-agent-config'
import type { TuiAgent } from '../shared/types'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
import {
PTY_STARTUP_INGRESS_VERSION,
PtyStartupIngress,
@@ -615,6 +616,8 @@ export class PtyHandler {
}
}
const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record<string, string>
// Why: an older client may not ask a newly upgraded relay to delete inherited shim state.
stripLegacyTerminalShimEnv(result, process.platform)
// Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values.
for (const key of envToDelete) {
delete result[key]
@@ -9,6 +9,7 @@ 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
@@ -2512,7 +2513,10 @@ describe('createRemoteRuntimePtyTransport', () => {
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: [
'agent-session.host-authority.v1',
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
]
},
_meta: { runtimeId: 'runtime-remote' }
}
@@ -2611,12 +2615,14 @@ describe('createRemoteRuntimePtyTransport', () => {
worktree: 'id:repo1::/remote/wt',
clientMutationId: expect.any(String),
command: 'claude',
env: { ORCA_TAB_ID: 'tab-1' },
env: { ORCA_TAB_ID: 'tab-1', ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
focus: false,
presentation: 'background'
},
expectedEnvironmentPairingRevision: undefined,
timeoutMs: 15_000
})
expect(runtimeSubscribe).toHaveBeenCalledWith(
@@ -2668,20 +2674,34 @@ describe('createRemoteRuntimePtyTransport', () => {
})
it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => {
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' }
})
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' }
}
)
const onPtySpawn = vi.fn()
const onReattachDetermined = vi.fn()
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
@@ -2809,6 +2829,7 @@ describe('createRemoteRuntimePtyTransport', () => {
},
presentation: 'background'
},
expectedEnvironmentPairingRevision: undefined,
timeoutMs: 15_000
})
expect(runtimeCall).not.toHaveBeenCalledWith(
@@ -2834,9 +2855,13 @@ describe('createRemoteRuntimePtyTransport', () => {
id: 'rpc-status',
ok: true,
result: {
runtimeId: 'runtime-remote',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: [
'agent-session.host-authority.v1',
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
]
}
}
)
@@ -2882,6 +2907,7 @@ describe('createRemoteRuntimePtyTransport', () => {
},
presentation: 'background'
},
expectedEnvironmentPairingRevision: undefined,
timeoutMs: 15_000
})
expect(runtimeCall).not.toHaveBeenCalledWith(
@@ -14,7 +14,12 @@ import {
TERMINAL_INPUT_MAX_BYTES
} from '../../../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
describe('createRemoteRuntimePtyTransport', () => {
const runtimeCall = vi.fn()
@@ -113,6 +118,19 @@ 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({
@@ -187,6 +205,18 @@ 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'
@@ -541,10 +571,7 @@ describe('createRemoteRuntimePtyTransport', () => {
let createCalls = 0
runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => {
if (args.method === 'status.get') {
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
}
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
}
if (args.method === 'terminal.create') {
createCalls += 1
@@ -592,13 +619,14 @@ describe('createRemoteRuntimePtyTransport', () => {
try {
const startedAt = Date.now()
let createCalls = 0
let statusCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'status.get') {
vi.setSystemTime(startedAt + 59_000)
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
statusCalls += 1
if (statusCalls > 1) {
vi.setSystemTime(startedAt + 59_000)
}
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
}
if (args.method === 'terminal.create') {
createCalls += 1
@@ -632,7 +660,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 { ok: true, result: { capabilities: [] } }
return currentRuntimeStatus()
}
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
@@ -653,12 +681,16 @@ 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') {
if (args.method === 'status.get' && ++statusCalls > 1) {
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'
})
@@ -694,10 +726,7 @@ describe('createRemoteRuntimePtyTransport', () => {
}, args.timeoutMs)
})
}
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
}
return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY])
}
if (args.method === 'terminal.create' && reachable) {
return { ok: true, result: { terminal: { handle: 'terminal-recovered' } } }
@@ -763,9 +792,10 @@ describe('createRemoteRuntimePtyTransport', () => {
return {
ok: true,
result: {
runtimeId: 'runtime-1',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -813,9 +843,65 @@ 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')).toHaveLength(
1
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
)
expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false)
transport.destroy?.()
} finally {
vi.useRealTimers()
@@ -3855,6 +3941,9 @@ 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
@@ -3870,6 +3959,11 @@ 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
@@ -3878,13 +3972,17 @@ describe('createRemoteRuntimePtyTransport', () => {
selector: 'env-1',
method: 'terminal.close',
params: { terminal: 'terminal-late' },
timeoutMs: 15_000
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: undefined
})
})
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
@@ -3900,6 +3998,11 @@ 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
@@ -3910,7 +4013,8 @@ describe('createRemoteRuntimePtyTransport', () => {
selector: 'env-1',
method: 'terminal.close',
params: { terminal: 'terminal-late' },
timeoutMs: 15_000
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: undefined
})
transport.destroy?.()
})
@@ -3924,7 +4028,7 @@ describe('createRemoteRuntimePtyTransport', () => {
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
})
}
@@ -3974,7 +4078,7 @@ describe('createRemoteRuntimePtyTransport', () => {
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
})
}
@@ -4089,7 +4193,7 @@ describe('createRemoteRuntimePtyTransport', () => {
method: 'terminal.create',
params: expect.objectContaining({
command: "codex 'linked issue context'",
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
startupCommandDelivery: 'shell-ready',
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
})
@@ -4105,7 +4209,11 @@ describe('createRemoteRuntimePtyTransport', () => {
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1']
capabilities: [
'agent-session.host-authority.v1',
'agent-session.omp-resume-path.v1',
'terminal.attribution-removed.v1'
]
}
}
: { ok: true, result: { terminal: { handle: 'terminal-1' } } }
@@ -4169,7 +4277,7 @@ describe('createRemoteRuntimePtyTransport', () => {
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
: {
@@ -4219,9 +4327,10 @@ describe('createRemoteRuntimePtyTransport', () => {
? {
ok: true,
result: {
runtimeId: 'runtime-1',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: []
capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY]
}
}
: { ok: true, result: { terminal: { handle: 'terminal-legacy' } } }
@@ -4257,7 +4366,12 @@ 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' },
env: {
CODEX_PROFILE: 'captured',
ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token',
ORCA_ATTRIBUTION_BYPASS: '1'
},
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
@@ -4269,7 +4383,9 @@ describe('createRemoteRuntimePtyTransport', () => {
focus: false,
presentation: 'background'
},
timeoutMs: 15_000
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: 'runtime-1'
})
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.createAgentSession' })
@@ -5686,6 +5802,9 @@ 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' } } })
}
@@ -5725,6 +5844,9 @@ 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' } } })
}
@@ -5770,6 +5892,9 @@ 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' } } })
}
@@ -5795,6 +5920,9 @@ 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' } } })
}
@@ -5834,6 +5962,9 @@ 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' } } })
}
@@ -5883,6 +6014,9 @@ 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' } } })
}
@@ -5921,6 +6055,9 @@ 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,8 +20,11 @@ import type {
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import {
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
@@ -34,7 +37,11 @@ import type {
PtyTransportRecoveryState
} from './pty-transport-types'
import { createPtyOutputProcessor } from './pty-transport'
import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client'
import {
RuntimeRpcCallError,
unwrapRuntimeRpcResult,
type LiveRuntimeEnvironmentAuthority
} from '../../runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle,
@@ -83,6 +90,10 @@ 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
@@ -92,6 +103,8 @@ 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'
@@ -358,6 +371,7 @@ 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,
@@ -852,14 +866,16 @@ export function createRemoteRuntimePtyTransport(
environmentId: string,
method: string,
params?: unknown,
timeoutMs = 15_000
timeoutMs = 15_000,
expectedRuntimeId?: string
): Promise<TResult> {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method,
params,
timeoutMs,
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
...(expectedRuntimeId ? { expectedRuntimeId } : {})
})
return unwrapRuntimeRpcResult(response as RuntimeRpcResponse<TResult>)
}
@@ -908,7 +924,8 @@ export function createRemoteRuntimePtyTransport(
reconcileExisting: boolean
) => Promise<RemoteAgentSessionLaunchResult>,
environmentId: string,
expectedLifecycleEpoch: number
expectedLifecycleEpoch: number,
beforeReplay?: (timeoutMs: number) => Promise<void>
): Promise<RemoteAgentSessionLaunchResult | null> {
let retryAttempt = 0
// Structured operations already carry their replay proof; ordinary terminal.create
@@ -993,6 +1010,9 @@ 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
@@ -1975,8 +1995,10 @@ export function createRemoteRuntimePtyTransport(
const commandToSend = options.command ?? command
const startupCommandDeliveryToSend =
options.startupCommandDelivery ?? startupCommandDelivery
const envToSend = options.env ?? env
const envToDeleteToSend = options.envToDelete ?? envToDelete
const envToSend = withLegacyTerminalAttributionDisabledEnv(options.env ?? env)
const envToDeleteToSend = addLegacyTerminalAttributionDisableRequest(
options.envToDelete ?? envToDelete
)
const launchConfigToSend = options.launchConfig ?? launchConfig
const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession
const launchTokenToSend = options.launchToken ?? launchToken
@@ -2004,7 +2026,7 @@ export function createRemoteRuntimePtyTransport(
presentation: 'background' as const,
...(activate === true ? { activate: true } : {})
}
const legacyCreate = () =>
const legacyCreate = ({ authority }: { authority: LiveRuntimeEnvironmentAuthority }) =>
createWithUnknownOutcomeRecovery(
'terminal',
(timeoutMs, reconcileExisting) =>
@@ -2015,13 +2037,49 @@ export function createRemoteRuntimePtyTransport(
...legacyCreateParams,
...(reconcileExisting ? { reconcileExisting: true } : {})
},
timeoutMs
timeoutMs,
authority.runtimeId
),
createEnvironmentId,
connectLifecycleEpoch
)
const hostAuthorityCreate = () =>
createWithUnknownOutcomeRecovery(
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(
'agent-session',
(timeoutMs) =>
resumeProviderSessionToSend
@@ -2043,7 +2101,8 @@ export function createRemoteRuntimePtyTransport(
placement: { tabId, leafId },
presentation: 'background'
},
timeoutMs
timeoutMs,
authority.runtimeId
)
: callRuntimeForEnvironment<RuntimeCreateAgentSessionResult>(
createEnvironmentId,
@@ -2065,23 +2124,37 @@ export function createRemoteRuntimePtyTransport(
},
agentCreateOperation.clientOperationId
),
timeoutMs
timeoutMs,
authority.runtimeId
),
createEnvironmentId,
connectLifecycleEpoch
connectLifecycleEpoch,
(timeoutMs) => revalidateAgentSessionReplay(timeoutMs, authority)
)
}
const created = launchAgentToSend
? agentSessionRequiresHostAuthorityReplay
? await hostAuthorityCreate()
? agentSessionCreateAuthority
? await hostAuthorityCreate(agentSessionCreateAuthority)
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
environmentId: createEnvironmentId,
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
hostAuthority: hostAuthorityCreate,
requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities,
legacy: legacyCreate
})
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
environmentId: createEnvironmentId,
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
hostAuthority: hostAuthorityCreate,
...(resumeProviderSessionToSend && launchAgentToSend === 'omp'
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
: {}),
requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities,
legacy: legacyCreate
})
: await legacyCreate()
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
environmentId: createEnvironmentId,
expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision,
legacy: legacyCreate
})
if (!created) {
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
connecting = false
@@ -2157,13 +2230,21 @@ 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 (
isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))
recoverable ||
terminalCreateNeedsReconciliation ||
agentSessionRequiresHostAuthorityReplay
) {
recovery.markDisconnected()
if (!recoverable) {
surfaceErrorMessage(message)
}
} else {
recovery.cancel()
emitRecoveryState()
@@ -378,6 +378,8 @@ 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}$/),
@@ -420,7 +422,7 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: []
capabilities: ['terminal.attribution-removed.v1']
}
})
}
@@ -442,6 +444,7 @@ 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,6 +3,11 @@ 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,
@@ -33,7 +38,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
const launchPreferences = toAgentLaunchPreferences(args.sessionOptions)
return await runRemoteAgentSessionLaunch({
environmentId: args.environmentId,
hostAuthority: () =>
requiredHostAuthorityCapabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY],
hostAuthority: (authority) =>
operation.run((clientOperationId) =>
callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>(
{ kind: 'environment', environmentId: args.environmentId },
@@ -52,10 +58,14 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
},
clientOperationId
),
{ timeoutMs: 15_000 }
{
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision,
expectedRuntimeId: authority.runtimeId
}
)
),
legacy: ({ skipCompatibilityCheck }) =>
legacy: ({ skipCompatibilityCheck, authority }) =>
callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>(
{ kind: 'environment', environmentId: args.environmentId },
'terminal.create',
@@ -65,7 +75,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
...(args.legacy.startupCommandDelivery
? { startupCommandDelivery: args.legacy.startupCommandDelivery }
: {}),
env: args.legacy.env,
env: withLegacyTerminalAttributionDisabledEnv(args.legacy.env),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
launchConfig: args.legacy.launchConfig,
launchToken: args.legacy.launchToken,
launchAgent: args.agent,
@@ -74,7 +85,12 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
leafId: args.leafId,
presentation: 'background'
},
{ timeoutMs: 15_000, skipCompatibilityCheck }
{
timeoutMs: 15_000,
skipCompatibilityCheck,
expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision,
expectedRuntimeId: authority.runtimeId
}
)
})
}
@@ -1,5 +1,6 @@
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,
@@ -23,6 +24,35 @@ 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,
@@ -536,16 +566,7 @@ describe('activateAndRevealWorktree', () => {
it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => {
const worktree = makeWebRuntimeWorktree()
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' }
})
const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id)
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('window', {
api: {
@@ -612,9 +633,19 @@ describe('activateAndRevealWorktree', () => {
})
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await new Promise((resolve) => setTimeout(resolve, 0))
await vi.waitFor(() => {
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
})
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
selector: 'web-runtime-1',
method: 'status.get'
})
)
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
selector: 'web-runtime-1',
method: 'session.tabs.createTerminal'
@@ -624,10 +655,7 @@ describe('activateAndRevealWorktree', () => {
it('respawns wake terminals on the explicit owner runtime when focus changed', async () => {
const worktree = makeWorktree()
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
ok: true,
result: { tabId: 'host-tab-1', terminal: 'term_host' }
})
const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id)
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('window', {
api: {
@@ -676,9 +704,19 @@ describe('activateAndRevealWorktree', () => {
})
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await new Promise((resolve) => setTimeout(resolve, 0))
await vi.waitFor(() => {
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
})
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
selector: 'owner-runtime',
method: 'status.get'
})
)
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
selector: 'owner-runtime',
method: 'session.tabs.createTerminal'
@@ -1,6 +1,7 @@
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'
@@ -48,21 +49,43 @@ 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().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
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
}
}
})
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
@@ -100,10 +123,18 @@ describe('empty remote worktree activation', () => {
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await vi.waitFor(() => {
expect(callRuntimeEnvironment).toHaveBeenCalled()
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3)
})
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
selector: 'web-runtime-1',
method: 'status.get'
})
)
expect(callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
selector: 'web-runtime-1',
method: 'session.tabs.createTerminal',
@@ -12,7 +12,8 @@ export async function callAbortableRuntimeEnvironment(
params: unknown,
timeoutMs: number | undefined,
signal: AbortSignal,
expectedEnvironmentPairingRevision?: number
expectedEnvironmentPairingRevision?: number,
expectedRuntimeId?: string
): Promise<RuntimeRpcResponse<unknown>> {
if (signal.aborted) {
throw createRuntimeRpcAbortError()
@@ -47,7 +48,14 @@ export async function callAbortableRuntimeEnvironment(
signal.addEventListener('abort', onAbort, { once: true })
void window.api.runtimeEnvironments
.subscribe(
{ selector: environmentId, method, params, timeoutMs, expectedEnvironmentPairingRevision },
{
selector: environmentId,
method,
params,
timeoutMs,
expectedEnvironmentPairingRevision,
expectedRuntimeId
},
{
onResponse: (response) => finish(() => resolve(response)),
onError: (error) => finish(() => reject(new Error(error.message))),
@@ -1,8 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
supportsCapability: vi.fn()
}))
const mocks = vi.hoisted(() => ({ probe: vi.fn() }))
vi.mock('./runtime-rpc-client', () => ({
RuntimeRpcCallError: class RuntimeRpcCallError extends Error {
@@ -12,75 +10,110 @@ vi.mock('./runtime-rpc-client', () => ({
this.code = response.error.code
}
},
runtimeEnvironmentSupportsCapability: mocks.supportsCapability
probeLiveRuntimeEnvironmentCapabilities: mocks.probe
}))
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.supportsCapability.mockReset()
mocks.probe.mockReset()
mocks.probe.mockResolvedValue({ supported: true, authority })
})
it('uses host authority only when the host advertises it', async () => {
it('uses one live all-capability probe before host authority', async () => {
const hostAuthority = vi.fn().mockResolvedValue('structured')
const legacy = vi.fn().mockResolvedValue('legacy')
await expect(
runRemoteAgentSessionLaunch({
environmentId: 'env-1',
expectedEnvironmentPairingRevision: 7,
hostAuthority,
requiredHostAuthorityCapabilities: ['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(legacy).not.toHaveBeenCalled()
})
it('uses fenced legacy when a structured-only capability is absent', async () => {
mocks.probe.mockResolvedValue({ supported: false, authority })
const hostAuthority = vi.fn().mockResolvedValue('structured')
const legacy = vi.fn().mockResolvedValue('legacy')
mocks.supportsCapability.mockResolvedValue(true)
await expect(
runRemoteAgentSessionLaunch({
environmentId: 'env-1',
hostAuthority,
hostAuthorityCapability: 'agent-session.omp-resume-path.v1',
requiredHostAuthorityCapabilities: ['agent-session.omp-resume-path.v1'],
legacy
})
).resolves.toBe('structured')
expect(mocks.supportsCapability).toHaveBeenCalledWith(
'env-1',
'agent-session.omp-resume-path.v1'
)
expect(hostAuthority).toHaveBeenCalledOnce()
).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('preserves the exact legacy path when the capability is absent', async () => {
const hostAuthority = vi.fn().mockResolvedValue('structured')
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')
mocks.supportsCapability.mockResolvedValue(false)
await expect(
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy })
).resolves.toBe('legacy')
expect(legacy).toHaveBeenCalledOnce()
expect(hostAuthority).not.toHaveBeenCalled()
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('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 () => {
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')
mocks.supportsCapability.mockRejectedValue(compatibilityError)
await expect(
runRemoteAgentSessionLaunch({
environmentId: 'env-1',
hostAuthority: vi.fn(),
legacy
})
runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy })
).rejects.toBe(compatibilityError)
expect(legacy).not.toHaveBeenCalled()
})
@@ -88,7 +121,6 @@ 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({
@@ -100,14 +132,13 @@ describe('remote agent-session launch routing', () => {
expect(legacy).not.toHaveBeenCalled()
})
it('uses legacy only for the host pre-side-effect lower-owner response', async () => {
const legacy = vi.fn().mockResolvedValue('legacy')
mocks.supportsCapability.mockResolvedValue(true)
it('uses fenced legacy for the pre-side-effect lower-owner response', async () => {
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({
@@ -116,17 +147,16 @@ describe('remote agent-session launch routing', () => {
legacy
})
).resolves.toBe('legacy')
expect(legacy).toHaveBeenCalledOnce()
expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority })
})
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)
it('does not downgrade when a replacement host rejects the structured method', async () => {
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({
@@ -134,16 +164,16 @@ describe('remote agent-session launch routing', () => {
hostAuthority: vi.fn().mockRejectedValue(methodNotFound),
legacy
})
).resolves.toBe('legacy')
expect(legacy).toHaveBeenCalledOnce()
).rejects.toBe(methodNotFound)
expect(legacy).not.toHaveBeenCalled()
})
it('uses legacy directly when no structured form exists', async () => {
it('probes and fences legacy when no structured form exists', async () => {
const legacy = vi.fn().mockResolvedValue('legacy')
await expect(runRemoteAgentSessionLaunch({ environmentId: 'env-1', legacy })).resolves.toBe(
'legacy'
)
expect(mocks.supportsCapability).not.toHaveBeenCalled()
expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority })
})
})
@@ -1,46 +1,61 @@
import { AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from '../../../shared/agent-session-host-authority'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import { RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
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 { isRuntimeCompatBlockError } from './runtime-protocol-compat'
export async function runRemoteAgentSessionLaunch<TResult>(args: {
environmentId: string
hostAuthority?: () => Promise<TResult>
hostAuthorityCapability?: RuntimeCapability
legacy: (options: { skipCompatibilityCheck: boolean }) => Promise<TResult>
expectedEnvironmentPairingRevision?: number
hostAuthority?: (authority: LiveRuntimeEnvironmentAuthority) => Promise<TResult>
requiredHostAuthorityCapabilities?: readonly RuntimeCapability[]
legacy: (options: {
skipCompatibilityCheck: boolean
authority: LiveRuntimeEnvironmentAuthority
}) => Promise<TResult>
}): Promise<TResult> {
if (!args.hostAuthority) {
return await args.legacy({ skipCompatibilityCheck: false })
}
let supported: boolean
const requiredCapabilities = [
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
...(args.hostAuthority ? [AGENT_SESSION_HOST_AUTHORITY_CAPABILITY] : []),
...(args.requiredHostAuthorityCapabilities ?? [])
]
let probe: Awaited<ReturnType<typeof probeLiveRuntimeEnvironmentCapabilities>>
try {
supported = await runtimeEnvironmentSupportsCapability(
args.environmentId,
args.hostAuthorityCapability ?? AGENT_SESSION_HOST_AUTHORITY_CAPABILITY
)
probe = await probeLiveRuntimeEnvironmentCapabilities({
environmentId: args.environmentId,
requiredCapabilities,
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision
})
} catch (error) {
if (isRuntimeCompatBlockError(error)) {
throw error
}
// 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 })
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: 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 })
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 })
}
try {
return await args.hostAuthority()
return await args.hostAuthority(probe.authority)
} catch (error) {
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 })
if (error instanceof RuntimeRpcCallError && error.code === 'agent_session_legacy_required') {
return await args.legacy({ skipCompatibilityCheck: true, authority: probe.authority })
}
throw error
}
@@ -0,0 +1,46 @@
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,7 +3,11 @@ import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../shared/protocol-version'
import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
import {
callRuntimeRpc,
clearRuntimeCompatibilityCacheForTests,
probeLiveRuntimeEnvironmentCapabilities
} from './runtime-rpc-client'
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
const runtimeEnvironmentCall = vi.fn()
@@ -62,3 +66,57 @@ 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,6 +19,8 @@ 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
@@ -54,6 +56,7 @@ export async function callRuntimeRpc<TResult>(
skipCompatibilityCheck?: boolean
signal?: AbortSignal
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
} = {}
): Promise<TResult> {
const expectedEnvironmentPairingRevision =
@@ -88,7 +91,8 @@ export async function callRuntimeRpc<TResult>(
params: nextParams,
timeoutMs: options.timeoutMs,
signal: options.signal,
expectedEnvironmentPairingRevision
expectedEnvironmentPairingRevision,
expectedRuntimeId: options.expectedRuntimeId
})
return unwrapRuntimeRpcResult<TResult>(response as RuntimeRpcResponse<TResult>)
}
@@ -0,0 +1,28 @@
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,6 +7,7 @@ export async function callRuntimeEnvironmentWithRevision(args: {
timeoutMs?: number
signal?: AbortSignal
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}): Promise<unknown> {
if (args.signal) {
return callAbortableRuntimeEnvironment(
@@ -15,7 +16,8 @@ export async function callRuntimeEnvironmentWithRevision(args: {
args.params,
args.timeoutMs,
args.signal,
args.expectedEnvironmentPairingRevision
args.expectedEnvironmentPairingRevision,
args.expectedRuntimeId
)
}
return window.api.runtimeEnvironments.call({
@@ -23,6 +25,7 @@ export async function callRuntimeEnvironmentWithRevision(args: {
method: args.method,
params: args.params,
timeoutMs: args.timeoutMs,
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision
expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision,
expectedRuntimeId: args.expectedRuntimeId
})
}
@@ -56,9 +56,12 @@ const mocks = vi.hoisted(() => ({
deliverLaunchPromptToAgentTab: vi.fn(),
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
getRuntimeEnvironmentIdForWorktree: vi.fn(),
hasMaterializedWebRuntimeBrowserPage: vi.fn()
hasMaterializedWebRuntimeBrowserPage: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('../store', () => ({
useAppStore: {
getState: mocks.getState,
@@ -112,6 +115,21 @@ 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()
@@ -1430,6 +1448,7 @@ describe('createWebRuntimeSessionTerminal', () => {
})
const runtimeCall = vi
.fn()
.mockResolvedValueOnce(makeAttributionSafeStatus())
.mockResolvedValueOnce({
id: 'create',
ok: true,
@@ -1450,6 +1469,43 @@ 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 },
@@ -1469,7 +1525,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -1558,7 +1614,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
@@ -1622,6 +1678,7 @@ 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}$/),
@@ -1666,6 +1723,7 @@ 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,
@@ -1691,8 +1749,10 @@ describe('createWebRuntimeSessionTerminal', () => {
})
).resolves.toEqual({ status: 'created' })
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
selector: ENVIRONMENT_ID,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: 'runtime-1',
method: 'session.tabs.createTerminal',
params: {
worktree: `id:${WORKTREE_ID}`,
@@ -1700,6 +1760,8 @@ 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,
@@ -1709,6 +1771,7 @@ describe('createWebRuntimeSessionTerminal', () => {
timeoutMs: 15_000
})
expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([
'status.get',
'session.tabs.createTerminal',
'session.tabs.list'
])
@@ -1725,6 +1788,7 @@ describe('createWebRuntimeSessionTerminal', () => {
})
const runtimeCall = vi
.fn()
.mockResolvedValueOnce(makeAttributionSafeStatus())
.mockResolvedValueOnce({
id: 'create-terminal',
ok: true,
@@ -1781,7 +1845,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -1842,7 +1906,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -1895,7 +1959,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: []
capabilities: ['terminal.attribution-removed.v1']
}
}
}
@@ -1926,6 +1990,8 @@ describe('createWebRuntimeSessionTerminal', () => {
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
selector: ENVIRONMENT_ID,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: 'old-runtime',
method: 'session.tabs.createTerminal',
params: {
worktree: `id:${WORKTREE_ID}`,
@@ -1933,6 +1999,8 @@ 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,
@@ -1959,7 +2027,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: []
capabilities: ['terminal.attribution-removed.v1']
}
}
}
@@ -1994,6 +2062,8 @@ describe('createWebRuntimeSessionTerminal', () => {
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
selector: ENVIRONMENT_ID,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: 'old-runtime',
method: 'session.tabs.createTerminal',
params: {
worktree: `id:${WORKTREE_ID}`,
@@ -2001,7 +2071,8 @@ describe('createWebRuntimeSessionTerminal', () => {
targetGroupId: undefined,
command: "codex resume 'session-1'",
cwd: undefined,
env: { CODEX_PROFILE: 'captured' },
env: { CODEX_PROFILE: 'captured', ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
startupCommandDelivery: undefined,
launchConfig: {
agentCommand: 'codex',
@@ -2030,7 +2101,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -2071,9 +2142,14 @@ 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' },
env: {
PI_CODING_AGENT_DIR: '/custom/omp',
ORCA_ATTRIBUTION_BYPASS: '1'
},
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
launchAgent: 'omp'
}
})
@@ -2090,7 +2166,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -2152,7 +2228,7 @@ describe('createWebRuntimeSessionTerminal', () => {
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1']
capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1']
}
}
}
@@ -2782,17 +2858,25 @@ describe('splitWebRuntimeTerminal', () => {
})
it('passes telemetry source to the host split while allowing the mirrored split event to be suppressed', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'split',
ok: true,
result: {
split: {
handle: 'terminal-2',
tabId: 'tab-1',
paneRuntimeId: -1
}
}
})
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
}
}
}
)
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
@@ -2811,13 +2895,22 @@ describe('splitWebRuntimeTerminal', () => {
consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-1', 'horizontal')
).toBe(true)
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
expect(runtimeCall).toHaveBeenCalledWith({
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
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
@@ -2826,11 +2919,19 @@ describe('splitWebRuntimeTerminal', () => {
it('does not track rejected host split RPCs', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const runtimeCall = vi.fn().mockResolvedValue({
id: 'split',
ok: false,
error: { code: 'terminal_exited', message: 'Terminal exited' }
})
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' }
}
)
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
@@ -2843,23 +2944,53 @@ describe('splitWebRuntimeTerminal', () => {
splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'context_menu')
).toBe(true)
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1))
expect(mocks.toastError).toHaveBeenCalledWith('Terminal exited')
expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled()
})
it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => {
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: 'split',
id: 'status',
ok: true,
result: {
split: {
handle: 'terminal-2',
tabId: 'tab-1',
ptyId: 'pty-2'
}
}
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'
}
}
}
)
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
@@ -2874,7 +3005,7 @@ describe('splitWebRuntimeTerminal', () => {
true
)
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2))
})
})
+73 -29
View File
@@ -8,6 +8,7 @@ import type {
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabsResult,
RuntimeSessionTabCloseReason,
RuntimeStatus,
RuntimeTerminalCreate,
RuntimeTerminalClose,
RuntimeTerminalSplit
@@ -18,7 +19,10 @@ import type {
SleepingAgentLaunchConfig,
AgentProviderSessionMetadata
} from '../../../shared/agent-session-resume'
import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import {
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import type {
AgentLaunchPreferences,
AgentPromptDelivery,
@@ -81,6 +85,15 @@ 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,
@@ -130,6 +143,7 @@ function captureRuntimeEnvironmentCall(
method: string
params?: unknown
timeoutMs?: number
expectedRuntimeId?: string
}) => Promise<RuntimeRpcResponse<unknown>> {
return (args) =>
window.api.runtimeEnvironments.call({
@@ -264,6 +278,14 @@ 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)
@@ -272,6 +294,8 @@ 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
@@ -283,7 +307,7 @@ async function createWebRuntimeSessionTerminalResult(
? undefined
: args.agentSessionKind === 'resume'
? args.providerSession
? async () =>
? async (authority: { runtimeId: string }) =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'terminal.ensureAgentSession',
@@ -301,11 +325,12 @@ async function createWebRuntimeSessionTerminalResult(
: {}),
presentation: 'background'
},
timeoutMs: 15_000
timeoutMs: 15_000,
expectedRuntimeId: authority.runtimeId
})) as RuntimeRpcResponse<RuntimeEnsureAgentSessionResult>
)
: undefined
: async () =>
: async (authority: { runtimeId: string }) =>
await createAgentSessionCreateOperation().run(async (clientOperationId) =>
unwrapRuntimeRpcResult(
(await callEnvironment({
@@ -328,7 +353,8 @@ async function createWebRuntimeSessionTerminalResult(
},
clientOperationId
),
timeoutMs: 15_000
timeoutMs: 15_000,
expectedRuntimeId: authority.runtimeId
})) as RuntimeRpcResponse<RuntimeCreateAgentSessionResult>
)
)
@@ -336,11 +362,15 @@ async function createWebRuntimeSessionTerminalResult(
terminal: CreatedAgentTerminalIdentity
}>({
environmentId,
expectedEnvironmentPairingRevision: intentOwner.pairingRevision,
...(hostAuthority ? { hostAuthority } : {}),
...(args.agentSessionKind === 'resume' && agent === 'omp'
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
: {}),
legacy: async () => {
requiredHostAuthorityCapabilities: [
TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY,
...(args.agentSessionKind === 'resume' && agent === 'omp'
? [AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY]
: [])
],
legacy: async ({ authority }) => {
const response = await callEnvironment({
method: 'session.tabs.createTerminal',
params: {
@@ -349,8 +379,8 @@ async function createWebRuntimeSessionTerminalResult(
targetGroupId: args.targetGroupId,
command: args.command,
cwd: args.cwd,
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
env,
...(envToDelete ? { envToDelete } : {}),
startupCommandDelivery: args.startupCommandDelivery,
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.launchToken ? { launchToken: args.launchToken } : {}),
@@ -362,7 +392,8 @@ async function createWebRuntimeSessionTerminalResult(
select: args.activate !== false,
navigation: 'caller'
},
timeoutMs: 15_000
timeoutMs: 15_000,
expectedRuntimeId: authority.runtimeId
})
const legacyCreated = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
@@ -394,6 +425,7 @@ async function createWebRuntimeSessionTerminalResult(
})
}
} else {
const status = await assertSessionTabCreateAttributionDisableSupported()
const response = await callEnvironment({
method: 'session.tabs.createTerminal',
params: {
@@ -402,8 +434,8 @@ async function createWebRuntimeSessionTerminalResult(
targetGroupId: args.targetGroupId,
command: args.command,
cwd: args.cwd,
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
env,
...(envToDelete ? { envToDelete } : {}),
startupCommandDelivery: args.startupCommandDelivery,
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.launchToken ? { launchToken: args.launchToken } : {}),
@@ -413,7 +445,8 @@ async function createWebRuntimeSessionTerminalResult(
select: args.activate !== false,
navigation: 'caller'
},
timeoutMs: 15_000
timeoutMs: 15_000,
expectedRuntimeId: status.runtimeId
})
const created = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
@@ -1185,26 +1218,37 @@ export function splitWebRuntimeTerminal(
direction,
pendingMirrorSuppressionId
)
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'terminal.split',
params: {
terminal: remote.handle,
direction,
telemetrySource
},
timeoutMs: 15_000
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
})
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }>)
})
.catch((error) => {
releasePendingMirrorSuppression()
console.warn(
'[web-runtime-session] failed to split terminal:',
error instanceof Error ? error.message : String(error)
)
const message = error instanceof Error ? error.message : String(error)
toast.error(message)
console.warn('[web-runtime-session] failed to split terminal:', message)
})
return true
}
@@ -0,0 +1,99 @@
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)
})
})
@@ -0,0 +1,68 @@
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]
}
+4
View File
@@ -84,6 +84,9 @@ 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 =
@@ -121,6 +124,7 @@ 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
+27 -2
View File
@@ -35,6 +35,25 @@ 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')
@@ -335,6 +354,7 @@ async function createSubscriptionServer(
pairing: PairingOffer
nextBinary: Promise<Uint8Array>
nextAuth: Promise<unknown>
nextRequest: Promise<Record<string, unknown>>
}> {
const serverKeyPair = generateKeyPair()
let resolveBinary: (bytes: Uint8Array) => void = () => {}
@@ -345,6 +365,10 @@ 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)
@@ -386,7 +410,8 @@ async function createSubscriptionServer(
return
}
const request = JSON.parse(plaintext) as { id: string }
const request = JSON.parse(plaintext) as { id: string } & Record<string, unknown>
resolveRequest(request)
sendEncrypted(ws, sharedKey, {
id: request.id,
ok: true,
@@ -419,7 +444,7 @@ async function createSubscriptionServer(
if (!pairing) {
throw new Error('Failed to create test pairing')
}
return { pairing, nextBinary, nextAuth }
return { pairing, nextBinary, nextAuth, nextRequest }
}
function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void {
+4 -2
View File
@@ -497,14 +497,16 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
params: unknown,
timeoutMs: number,
callbacks: RemoteRuntimeSubscriptionCallbacks<TResult>,
livenessOptions?: RemoteRuntimeSocketLivenessOptions
livenessOptions?: RemoteRuntimeSocketLivenessOptions,
envelope?: RuntimeOrchestrationEnvelope
): Promise<RemoteRuntimeSubscription> {
const requestId = randomUUID()
const serializedRequest = serializeRemoteRuntimeRpcRequest({
requestId,
deviceToken: pairing.deviceToken,
method,
params
params,
envelope
})
const serializedAuth = serializeRemoteRuntimePayload({
type: 'e2ee_auth',
@@ -71,6 +71,7 @@ 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,6 +12,7 @@ export function admitSharedControlSubscription(args: {
deviceToken: string
method: string
params: unknown
expectedRuntimeId?: string
}): number {
if (args.subscriptions.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) {
throw new RemoteRuntimeClientError(
@@ -33,12 +34,18 @@ export function admitSharedControlSubscription(args: {
return retainedParamsBytes
}
function serializeRequest(args: { deviceToken: string; method: string; params: unknown }): void {
function serializeRequest(args: {
deviceToken: string
method: string
params: unknown
expectedRuntimeId?: string
}): void {
serializeRemoteRuntimeRpcRequest({
requestId: '00000000-0000-4000-8000-000000000000',
deviceToken: args.deviceToken,
method: args.method,
params: args.params
params: args.params,
envelope: { expectedRuntimeId: args.expectedRuntimeId }
})
}
@@ -37,7 +37,7 @@ export class RemoteRuntimeSharedControlConnection {
private sharedKey: Uint8Array | null = null
private socketCleanup: (() => void) | null = null
private readonly reconnect = new SharedControlReconnectScheduler()
private readonly readyStableReset: SharedControlReadyStableResetTimer
private readonly stableReset: SharedControlReadyStableResetTimer
private intentionallyClosed = false
private lastConnectedAt: number | null = null
private lastClose: { code: number; reason: string } | null = null
@@ -51,15 +51,13 @@ export class RemoteRuntimeSharedControlConnection {
constructor(
private readonly pairing: PairingOffer,
private readonly options: {
private readonly opts: {
environmentId?: string
reconnectStableResetMs?: number
liveness?: RemoteRuntimeSocketLivenessOptions
} = {}
) {
this.readyStableReset = new SharedControlReadyStableResetTimer(
options.reconnectStableResetMs ?? 30_000
)
this.stableReset = new SharedControlReadyStableResetTimer(opts.reconnectStableResetMs ?? 30_000)
}
request<TResult>(
@@ -85,13 +83,15 @@ export class RemoteRuntimeSharedControlConnection {
method: string,
params: unknown,
timeoutMs: number,
callbacks: SharedControlSubscriptionCallbacks<TResult>
callbacks: SharedControlSubscriptionCallbacks<TResult>,
expectedRuntimeId?: string
): 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.options.liveness,
options: this.opts.liveness,
onDead: (error) => this.handleSocketClosed(error, socketGeneration)
}
})
@@ -204,7 +204,7 @@ export class RemoteRuntimeSharedControlConnection {
frame,
state: this.state,
sharedKey: this.sharedKey,
environmentId: this.options.environmentId,
environmentId: this.opts.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.readyStableReset.schedule({
this.stableReset.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.options.environmentId,
environmentId: this.opts.environmentId,
state: this.state,
pendingRequests: this.pendingRequests,
subscriptions: this.subscriptions,
@@ -311,7 +311,7 @@ export class RemoteRuntimeSharedControlConnection {
ws: this.ws,
error,
preserveReadyWaitersAndPendingRequests,
clearReadyStableTimer: () => this.readyStableReset.clear()
clearReadyStableTimer: () => this.stableReset.clear()
})
this.ws = this.sharedKey = null
this.socketCleanup = null
@@ -36,7 +36,8 @@ export function sendSharedControlSubscription(args: {
id: args.subscription.requestId,
deviceToken: args.deviceToken,
method: args.subscription.method,
params: args.subscription.params
params: args.subscription.params,
expectedRuntimeId: args.subscription.expectedRuntimeId
})
) {
args.subscription.sent = true
@@ -14,6 +14,7 @@ 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
@@ -23,13 +24,15 @@ export async function startSharedControlSubscription<TResult>(args: {
subscriptions: args.subscriptions,
deviceToken: args.deviceToken,
method: args.method,
params: args.params
params: args.params,
expectedRuntimeId: args.expectedRuntimeId
})
const requestId = randomUUID()
const subscription = createSharedControlSubscription({
requestId,
method: args.method,
params: args.params,
expectedRuntimeId: args.expectedRuntimeId,
retainedParamsBytes,
callbacks: args.callbacks
})
@@ -1,5 +1,6 @@
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,
@@ -33,6 +34,39 @@ 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,6 +14,7 @@ export function createSharedControlSubscription<TResult>(args: {
requestId: string
method: string
params: unknown
expectedRuntimeId?: string
retainedParamsBytes: number
callbacks: SharedControlSubscriptionCallbacks<TResult>
}): SharedControlLogicalSubscription<TResult> {
@@ -21,6 +22,7 @@ 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,6 +33,7 @@ export type SharedControlLogicalSubscription<TResult = unknown> = {
requestId: string
method: string
params: unknown
expectedRuntimeId?: string
retainedParamsBytes: number
callbacks: SharedControlSubscriptionCallbacks<TResult>
sent: boolean
+2
View File
@@ -76,6 +76,8 @@ 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