Fix Windows ConPTY OSC color reply leaks at the PTY owner (#9651)

This commit is contained in:
OrcaWin 2026-07-20 23:50:25 -04:00 committed by GitHub
parent ede57cf723
commit cc44acaaa3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 798 additions and 193 deletions

View File

@ -2,7 +2,26 @@
Date: 2026-07-19
Status: Final decision; implementation pending
Status: Implemented with the 2026-07-20 ownership amendment below
## 2026-07-20 Ownership Amendment
Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for
the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so
renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder.
The PTY-owning process now classifies the backend from its own platform and the shell that actually
won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11
queries before model, replay, or view delivery. During the bounded startup window it replies once
when validated theme colors are available; after the deadline, or without colors, it consumes the
query without replying. A consuming-view handshake does not transfer this authority. Split query
candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a
candidate that proves malformed is released unchanged.
WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the
startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime
PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is
selected only by the authoritative owner backend, never by renderer or connection metadata.
## Problem
@ -31,13 +50,13 @@ the failure requires an additional agent, timing, input-mode, or replay conditio
## Root Cause
The startup OSC responder currently runs in Electron main only after runtime ingestion
([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
renderer-only filter can therefore hide the symptom without removing it from the authoritative
model, daemon history, snapshots, or remote delivery.
OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY,
WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners
([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session`
advances sequence state, writes its emulator, persists pending output, and fans out data before
Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only
filter can therefore misclassify paired runtimes and hide the symptom without removing it from the
authoritative model, daemon history, snapshots, or remote delivery.
The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
@ -228,14 +247,15 @@ When registered for an agent spawn, it:
hidden model nor a delivered renderer can answer it a second time;
6. begins echo recognition as soon as each individual reply is written.
If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
to normal query authority. A reattach never registers startup response state, matching current
behavior.
If attributes or a provider are unavailable, native ConPTY consumes the query without replying;
WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers
startup response state, matching current behavior, but native ConPTY ownership still prevents a
downstream reply.
### Exact authority transfer
Startup query interception opens only for a fresh session whose atomic creation installed the
transaction before buffered output release. It closes at the first of:
Startup query response authority opens only for a fresh session whose atomic creation installed the
transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of:
- both OSC 10 and OSC 11 slots have been answered;
- the startup deadline expires;
@ -243,7 +263,9 @@ transaction before buffered output release. It closes at the first of:
hidden-runtime ownership mark is established;
- the spawn fails, is cancelled, reattaches, or exits.
Closing query interception does not discard already-written reply candidates. Echo recognition has
Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source
replies, while complete queries remain consumed for the life of the PTY. Closing response authority
does not discard already-written reply candidates. Echo recognition has
its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control

View File

@ -3,9 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(24)
expect(PROTOCOL_VERSION).toBe(25)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
})
})

View File

@ -1,10 +1,10 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
export const PROTOCOL_VERSION = 24
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
export const PROTOCOL_VERSION = 25
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {

View File

@ -151,10 +151,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
...(process.platform === 'win32'
? { echoProjection: 'windows-conpty-esc-stripped' as const }
: {})
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'

View File

@ -184,6 +184,9 @@ describe('DaemonServer', () => {
isNew: true,
pid: 55555
})
await expect(
c.request('closeStartupQueryAuthority', { sessionId: 'test-session' })
).resolves.toEqual({ appliedSeq: 0 })
})
it('keeps RPC responsive and creates one subprocess while spawn preparation is pending', async () => {

View File

@ -22,7 +22,6 @@ import { checkPtySpawnHealth } from './pty-subprocess'
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority'
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
@ -699,13 +698,7 @@ export class DaemonServer {
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
shellReadySupported: p.shellReadySupported,
historySeed: p.historySeed,
startupIngress: parsePtyStartupIngressIntent(p.startupIngress, {
allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({
connectionId: null,
cwd: p.cwd,
shellOverride: p.shellOverride
})
}),
startupIngress: parsePtyStartupIngressIntent(p.startupIngress),
...(p.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
@ -779,6 +772,11 @@ export class DaemonServer {
this.cancelPendingPtySpawnPreparations(request.payload.sessionId)
return {}
case 'closeStartupQueryAuthority':
return {
appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId)
}
case 'write':
try {
this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now())

View File

@ -109,8 +109,8 @@ describe('Session', () => {
startupIngress?: {
colors: { foreground: string; background: string }
deadlineMs: number
echoProjection?: 'windows-conpty-esc-stripped'
}
ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl'
wslDistro?: string
}): Session {
session = new Session({
@ -120,6 +120,7 @@ describe('Session', () => {
...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}),
wslDistro: opts?.wslDistro,
subprocess,
...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
shellReadySupported: opts?.shellReadySupported ?? false,
...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}),
...(opts?.shellReadyTimeoutMs !== undefined
@ -196,10 +197,10 @@ describe('Session', () => {
it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => {
createSession({
ownerBackend: 'windows-conpty',
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
echoProjection: 'windows-conpty-esc-stripped'
deadlineMs: 5_000
}
})
const onData = vi.fn()
@ -229,10 +230,10 @@ describe('Session', () => {
it('releases a held cooked-echo prefix before taking a snapshot', () => {
createSession({
ownerBackend: 'windows-conpty',
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
echoProjection: 'windows-conpty-esc-stripped'
deadlineMs: 5_000
}
})
subprocess.simulateData('\x1b]10;?\x07')
@ -243,6 +244,53 @@ describe('Session', () => {
expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/')
expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length)
})
it('reproduces the legacy paired-runtime leak and removes its downstream producer', () => {
const query = '\x1b]10;?\x07'
const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\'
const projectedEcho = ']10;rgb:2e2e/3434/3434\\'
createSession({ ownerBackend: 'posix-pty' })
session.closeStartupQueryAuthority()
const legacyReplyProducers: string[] = []
const legacyOnData = vi.fn((data: string) => {
if (data === query) {
legacyReplyProducers.push('remote-visible-renderer')
session.write(reply)
if (subprocess.written.at(-1) === reply) {
subprocess.simulateData(projectedEcho)
}
}
})
session.attachClient({ onData: legacyOnData, onExit: () => {} })
subprocess.simulateData(query)
expect(legacyReplyProducers).toEqual(['remote-visible-renderer'])
expect(subprocess.written).toEqual([reply])
expect(legacyOnData.mock.calls).toEqual([[query], [projectedEcho]])
expect(session.getSnapshot()?.snapshotAnsi).toContain(projectedEcho)
session.dispose()
subprocess = createMockSubprocess()
createSession({ ownerBackend: 'windows-conpty' })
session.closeStartupQueryAuthority()
const fixedReplyProducers: string[] = []
const fixedOnData = vi.fn((data: string) => {
if (data === query) {
fixedReplyProducers.push('remote-visible-renderer')
session.write(reply)
}
})
session.attachClient({ onData: fixedOnData, onExit: () => {} })
subprocess.simulateData(query)
subprocess.simulateData('prompt')
expect(fixedReplyProducers).toEqual([])
expect(subprocess.written).toEqual([])
expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']])
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
})
})
describe('write', () => {

View File

@ -24,6 +24,7 @@ import type {
TakePendingOutputResult,
TerminalSnapshot
} from './types'
import type { PtyOwnerBackend } from '../../shared/pty-owner-backend'
const SHELL_READY_TIMEOUT_MS = 15_000
// Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex.
@ -85,6 +86,7 @@ export type SessionOptions = {
// a reaper, dead sessions and their scrollback emulators accumulate for the daemon's lifetime.
onExit?: (code: number) => void
startupIngress?: PtyStartupIngressIntent
ownerBackend?: PtyOwnerBackend
}
type AttachedClient = {
@ -158,6 +160,7 @@ export class Session {
this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue())
this.startupIngress = new PtyStartupIngress({
...(opts.startupIngress ? { intent: opts.startupIngress } : {}),
...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
write: (data) => this.subprocess.write(data),
onEmission: (emission) => this.emitSubprocessOutput(emission)
})

View File

@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
type TestSubprocess = SubprocessHandle & {
emitData: (data: string) => void
}
function createSubprocess(shellPath: string): TestSubprocess {
let onData: ((data: string) => void) | null = null
let onExit: ((code: number) => void) | null = null
return {
pid: 99_999,
shellPath,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: (callback) => {
onData = callback
},
onExit: (callback) => {
onExit = callback
},
dispose: vi.fn(),
emitData: (data) => onData?.(data)
}
}
describe('TerminalHost PTY owner backend', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
let host: TerminalHost
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(async () => {
await host?.dispose()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
async function createSession(
shellPath: string,
requestedWslDistro?: string,
onData = vi.fn()
): Promise<TestSubprocess> {
const subprocess = createSubprocess(shellPath)
host = new TerminalHost({ spawnSubprocess: () => subprocess })
await host.createOrAttach({
sessionId: 'owner-test',
cols: 80,
rows: 24,
...(requestedWslDistro
? { shellOverride: 'wsl.exe', terminalWindowsWslDistro: requestedWslDistro }
: { shellOverride: 'powershell.exe' }),
streamClient: { onData, onExit: vi.fn() }
})
return subprocess
}
it('uses the spawned native shell over stale requested WSL metadata', async () => {
const replyProducers: string[] = []
const onData = vi.fn((data: string) => {
if (data === '\x1b]10;?\x07') {
replyProducers.push('renderer')
host.write('owner-test', '\x1b]10;rgb:ffff/ffff/ffff\x1b\\')
}
})
const subprocess = await createSession('powershell.exe', 'Ubuntu', onData)
subprocess.emitData('\x1b]10;?\x07')
expect(replyProducers).toEqual([])
expect(onData).toHaveBeenCalledWith('', '\x1b]10;?\x07'.length, true, '\x1b]10;?\x07'.length)
expect(subprocess.write).not.toHaveBeenCalled()
})
it('keeps replies for an actually spawned WSL shell', async () => {
const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\'
const replyProducers: string[] = []
const onData = vi.fn((data: string) => {
if (data === '\x1b]10;?\x07') {
replyProducers.push('renderer')
host.write('owner-test', reply)
}
})
const subprocess = await createSession('wsl.exe', undefined, onData)
subprocess.emitData('\x1b]10;?\x07')
expect(replyProducers).toEqual(['renderer'])
expect(subprocess.write).toHaveBeenCalledWith(reply)
})
})

View File

@ -1,6 +1,7 @@
import { Session } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { resolveProcessCwd } from '../providers/process-cwd'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import {
@ -111,6 +112,11 @@ export class TerminalHost {
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
launchAgent: opts.launchAgent,
subprocess,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: subprocess.shellPath,
wslDistro
}),
shellReadySupported,
historySeed: opts.historySeed,
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),

View File

@ -2849,6 +2849,13 @@ export function registerPtyHandlers(
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
if (startupTerminalColorQueryReplyColors) {
spawnOptions.startupIngress = {
colors: startupTerminalColorQueryReplyColors,
deadlineMs: 5_000
}
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
@ -3831,10 +3838,7 @@ export function registerPtyHandlers(
if (startupTerminalColorQueryReplyColors) {
spawnOptions.startupIngress = {
colors: startupTerminalColorQueryReplyColors,
deadlineMs: 5_000,
...(nativeWindowsConptySpawn
? { echoProjection: 'windows-conpty-esc-stripped' as const }
: {})
deadlineMs: 5_000
}
}
const existingPaneSpawn = reservationPaneKey

View File

@ -1612,6 +1612,7 @@ describe('LocalPtyProvider', () => {
})
it('classifies startup queries before runtime and public data listeners', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const runtimeData = vi.fn()
const dataHandler = vi.fn()
provider.configure({ onData: runtimeData })
@ -1621,8 +1622,7 @@ describe('LocalPtyProvider', () => {
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
echoProjection: 'windows-conpty-esc-stripped'
deadlineMs: 5_000
}
})
const onDataCb = mockProc.onData.mock.calls[0][0]
@ -1652,6 +1652,47 @@ describe('LocalPtyProvider', () => {
])
})
it('consumes a native Windows OSC color query before renderer delivery', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const dataHandler = vi.fn()
provider.onData(dataHandler)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
const onDataCb = mockProc.onData.mock.calls[0][0]
const query = '\x1b]10;?\x07'
onDataCb(query)
expect(dataHandler).toHaveBeenCalledWith({
id,
data: '',
sequenceChars: query.length,
seq: query.length,
transformed: true
})
expect(mockProc.write).not.toHaveBeenCalled()
})
it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
const onDataCb = mockProc.onData.mock.calls[0][0]
const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
onDataCb('\x1b]11;?\x07')
provider.write(id, reply)
expect(mockProc.write).toHaveBeenCalledWith(reply)
})
it('notifies exit listeners when PTY exits', async () => {
const exitHandler = vi.fn()
provider.onExit(exitHandler)

View File

@ -66,6 +66,7 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@ -878,6 +879,11 @@ export class LocalPtyProvider implements IPtyProvider {
}
const startupIngress = new PtyStartupIngress({
...(args.startupIngress ? { intent: args.startupIngress } : {}),
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath,
wslDistro: spawnedWslDistro
}),
write: (data) => proc.write(data),
onEmission: emitIngressData
})

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
@ -60,7 +61,10 @@ describe('SshPtyProvider', () => {
expect(mux.request).toHaveBeenCalledWith(
'pty.spawn',
expect.objectContaining({ startupIngressVersion: 1, startupIngress })
expect.objectContaining({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress
})
)
})

View File

@ -98,6 +98,10 @@ import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
import { TERMINAL_METHODS } from './rpc/methods/terminal'
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
import {
_resetTerminalViewAttributesForTest,
setTerminalViewAttributes
} from './terminal-view-attribute-store'
const ORIGINAL_PLATFORM = process.platform
const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform')
@ -587,6 +591,7 @@ vi.mock('../git/git-username', async () => {
function resetRuntimeTestMocks(): void {
resetPlatform()
_resetTerminalViewAttributesForTest()
advertisedUrlWatcher.clear()
electronMocks.BrowserWindow.fromId.mockReset()
electronMocks.BrowserWindow.fromId.mockReturnValue(null)
@ -10246,6 +10251,37 @@ describe('OrcaRuntimeService', () => {
})
})
it('passes cached view colors to background agent spawns for source-owned startup replies', async () => {
setTerminalViewAttributes({
foreground: [0xff, 0xff, 0xff],
background: [0x28, 0x2c, 0x34],
cursor: [0xff, 0xff, 0xff],
ansi: Array.from({ length: 256 }, () => [0, 0, 0] as [number, number, number]),
colorSchemeMode: 'dark',
cursorStyle: 'block',
cursorBlink: false
})
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'codex' })
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
terminalColorQueryReplies: {
foreground: '#ffffff',
background: '#282c34'
}
})
)
})
it('applies Settings agent defaults to bare agent command terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {

View File

@ -14,6 +14,7 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
import type { AgentStatus } from '../../shared/agent-detection'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
import {
createTerminalTitleTracker,
stripBrailleSpinnerGlyphs,
@ -759,6 +760,7 @@ import {
} from './terminal-model-query-authority'
import {
getTerminalViewAttributes,
getTerminalViewColorQueryReplyColors,
registerTerminalViewAttributesApplier
} from './terminal-view-attribute-store'
import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown'
@ -1077,6 +1079,7 @@ type TerminalCreateOptions = {
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
launchAgent?: TuiAgent
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
viewMode?: 'terminal' | 'chat'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
telemetry?: WorktreeStartupLaunch['telemetry']
@ -1276,6 +1279,7 @@ type RuntimePtyController = {
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
terminalColorQueryReplies?: { foreground?: string; background?: string }
}): Promise<{ id: string; wslDistro?: string }>
write(ptyId: string, data: string): boolean
kill(ptyId: string): boolean
@ -19190,6 +19194,8 @@ export class OrcaRuntimeService {
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
const result = await this.ptyController.spawn({
cols: 120,
rows: 40,
@ -19211,6 +19217,7 @@ export class OrcaRuntimeService {
preAllocatedHandle,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches

View File

@ -915,6 +915,12 @@ const TerminalCreateParams = z.object({
.optional(),
launchToken: OptionalString,
launchAgent: z.string().refine(isTuiAgent).optional(),
terminalColorQueryReplies: z
.object({
foreground: z.string().max(128).optional(),
background: z.string().max(128).optional()
})
.optional(),
title: OptionalString,
focus: z.unknown().optional(),
rendererBacked: z.unknown().optional(),
@ -1350,6 +1356,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,

View File

@ -3045,6 +3045,7 @@ describe('OrcaRuntimeRpcServer', () => {
params: {
worktree: 'id:repo-1::/tmp/worktree-a',
command: "claude 'work on the issue'",
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' },
tabId: 'laptop-tab',
leafId,
presentation: 'background'
@ -3064,6 +3065,11 @@ describe('OrcaRuntimeRpcServer', () => {
expect(
(createResponse.result as { terminal?: { warning?: string } } | undefined)?.terminal?.warning
).toBeUndefined()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
})
)
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456)
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456)

View File

@ -13,6 +13,7 @@ import {
terminalViewAttributesEqual,
type TerminalViewAttributes
} from '../../shared/terminal-view-attributes'
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives
// the push, the runtime emulators consult it at reply time via the getter.
@ -50,6 +51,20 @@ export function getTerminalViewAttributes(): TerminalViewAttributes | null {
return currentAttributes
}
function rgbToCssHex(rgb: readonly [number, number, number]): string {
return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
}
export function getTerminalViewColorQueryReplyColors(): TerminalOscColorQueryReplyColors | null {
if (!currentAttributes) {
return null
}
return {
foreground: rgbToCssHex(currentAttributes.foreground),
background: rgbToCssHex(currentAttributes.background)
}
}
/** Test seam: reset module state between tests. */
export function _resetTerminalViewAttributesForTest(): void {
currentAttributes = null

View File

@ -1010,29 +1010,131 @@ describe('PtyHandler', () => {
})
it('leaves startup queries untouched for an unsupported relay capability version', async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
})
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'
dataCallback!(query)
vi.advanceTimersByTime(8)
const query = '\x1b]10;?\x07'
dataCallback!(query)
vi.advanceTimersByTime(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('consumes a color query at a native Windows SSH relay owner', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', { shellOverride: 'powershell.exe' })
dataCallback!('\x1b]10;?\x07')
vi.advanceTimersByTime(8)
expect(term.write).not.toHaveBeenCalled()
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '',
rawLength: '\x1b]10;?\x07'.length,
seq: '\x1b]10;?\x07'.length,
transformed: true
})
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('forwards color queries from a POSIX SSH relay owner', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' })
const query = '\x1b]10;?\x07'
dataCallback!(query)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('keeps renderer color replies for a Windows SSH relay that owns WSL', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
dataCallback!('\x1b]11;?\x07')
vi.advanceTimersByTime(8)
dispatcher.callNotification('pty.data', { id: 'pty-1', data: reply })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]11;?\x07'
})
expect(term.write).toHaveBeenCalledWith(reply)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('coalesces background PTY output before notifying the client', async () => {

View File

@ -45,6 +45,7 @@ import {
parsePtyStartupIngressIntent,
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend'
function isMissingNodePtyNativeBinding(error: unknown): boolean {
return (
@ -80,6 +81,7 @@ type ManagedPty = {
gracefulKillSent?: boolean
startupIngress?: PtyStartupIngress
startupIngressIntent?: ReturnType<typeof parsePtyStartupIngressIntent>
ownerBackend: PtyOwnerBackend
}
type PendingPtyOutput = {
@ -500,6 +502,7 @@ export class PtyHandler {
}
managed.startupIngress ??= new PtyStartupIngress({
...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}),
ownerBackend: managed.ownerBackend,
write: (data) => managed.pty.write(data),
onEmission: emitIngressData
})
@ -901,9 +904,7 @@ export class PtyHandler {
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
const startupIngressIntent =
params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION
? parsePtyStartupIngressIntent(params.startupIngress, {
allowWindowsEchoProjection: false
})
? parsePtyStartupIngressIntent(params.startupIngress)
: undefined
const managed: ManagedPty = {
id,
@ -917,6 +918,11 @@ export class PtyHandler {
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: shell,
wslDistro: terminalWindowsWslDistro
}),
...(startupIngressIntent ? { startupIngressIntent } : {}),
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
@ -1324,6 +1330,10 @@ export class PtyHandler {
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: shell
}),
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {})
})

View File

@ -1140,7 +1140,8 @@ describe('createRemoteRuntimePtyTransport', () => {
leafId: 'pane:1',
command: "codex 'linked issue context'",
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
startupCommandDelivery: 'shell-ready'
startupCommandDelivery: 'shell-ready',
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
})
await transport.connect({ url: '', callbacks: {} })
@ -1152,7 +1153,8 @@ describe('createRemoteRuntimePtyTransport', () => {
params: expect.objectContaining({
command: "codex 'linked issue context'",
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
startupCommandDelivery: 'shell-ready'
startupCommandDelivery: 'shell-ready',
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
})
})
)

View File

@ -70,6 +70,7 @@ export function createRemoteRuntimePtyTransport(
launchConfig,
launchToken,
launchAgent,
terminalColorQueryReplies,
worktreeId,
tabId,
leafId,
@ -791,6 +792,7 @@ export function createRemoteRuntimePtyTransport(
...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}),
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
tabId,
leafId,
focus: false,

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { resolvePtyOwnerBackend } from './pty-owner-backend'
describe('resolvePtyOwnerBackend', () => {
it.each([
['win32', 'powershell.exe', null, 'windows-conpty'],
['win32', 'C:\\Program Files\\Git\\bin\\bash.exe', null, 'windows-conpty'],
['win32', undefined, null, 'windows-conpty'],
['win32', 'wsl.exe', null, 'windows-wsl'],
['win32', 'C:\\Windows\\System32\\wsl.exe', 'Ubuntu', 'windows-wsl'],
['win32', 'powershell.exe', 'Ubuntu', 'windows-conpty'],
['win32', undefined, 'Ubuntu', 'windows-wsl'],
['darwin', '/bin/zsh', null, 'posix-pty'],
['linux', '/bin/bash', null, 'posix-pty']
] as const)('%s %s with %s resolves to %s', (platform, shellPath, wslDistro, expected) => {
expect(resolvePtyOwnerBackend({ platform, shellPath, wslDistro })).toBe(expected)
})
})

View File

@ -0,0 +1,24 @@
export type PtyOwnerBackend = 'posix-pty' | 'windows-conpty' | 'windows-wsl'
function shellBasename(shellPath: string | undefined): string {
return shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
}
export function resolvePtyOwnerBackend(args: {
platform: NodeJS.Platform
shellPath: string | undefined
wslDistro?: string | null
}): PtyOwnerBackend {
if (args.platform !== 'win32') {
return 'posix-pty'
}
const shell = shellBasename(args.shellPath)
if (shell === 'wsl.exe' || shell === 'wsl') {
return 'windows-wsl'
}
// Why: requested WSL metadata can survive a spawn fallback; the winning native shell owns the PTY.
if (!shell && args.wslDistro) {
return 'windows-wsl'
}
return 'windows-conpty'
}

View File

@ -0,0 +1,55 @@
import type { PtyOwnerBackend } from './pty-owner-backend'
import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
export type PtyIngressEmission = {
data: string
rawStartSeq: number
rawEndSeq: number
transformed: boolean
}
export type PtyStartupIngressOptions = {
intent?: PtyStartupIngressIntent
ownerBackend?: PtyOwnerBackend
write: (data: string) => void
onEmission: (emission: PtyIngressEmission) => void
}
export type PtyIngressSourceSpan = {
data: string
rawStartSeq: number
rawEndSeq: number
}
export type PtyStartupIngressOperation =
| { kind: 'data'; chunk: PtyIngressSourceSpan }
| { kind: 'close-query' }
| { kind: 'snapshot' }
| { kind: 'teardown' }
| { kind: 'expire' }
export function slicePtyIngressSourceSpan(
span: PtyIngressSourceSpan,
start: number,
end = span.data.length
): PtyIngressSourceSpan {
return {
data: span.data.slice(start, end),
rawStartSeq: span.rawStartSeq + start,
rawEndSeq: span.rawStartSeq + end
}
}
export function combinePtyIngressSourceSpans(
first: PtyIngressSourceSpan | null,
second: PtyIngressSourceSpan
): PtyIngressSourceSpan {
if (!first) {
return second
}
return {
data: first.data + second.data,
rawStartSeq: first.rawStartSeq,
rawEndSeq: second.rawEndSeq
}
}

View File

@ -6,15 +6,11 @@ import {
export type PtyStartupIngressIntent = {
colors: TerminalOscColorQueryReplyColors
deadlineMs: number
echoProjection?: 'windows-conpty-esc-stripped'
}
export const PTY_STARTUP_INGRESS_VERSION = 1
export const PTY_STARTUP_INGRESS_VERSION = 2
export function parsePtyStartupIngressIntent(
value: unknown,
options: { allowWindowsEchoProjection: boolean }
): PtyStartupIngressIntent | undefined {
export function parsePtyStartupIngressIntent(value: unknown): PtyStartupIngressIntent | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
@ -37,16 +33,8 @@ export function parsePtyStartupIngressIntent(
) {
return undefined
}
const projection = record.echoProjection
if (
projection !== undefined &&
(projection !== 'windows-conpty-esc-stripped' || !options.allowWindowsEchoProjection)
) {
return undefined
}
return {
colors: normalizedColors,
deadlineMs: record.deadlineMs,
...(projection === 'windows-conpty-esc-stripped' ? { echoProjection: projection } : {})
deadlineMs: record.deadlineMs
}
}

View File

@ -14,9 +14,9 @@ function createHarness(options: { projection?: boolean; nested?: (data: string)
ingress = new PtyStartupIngress({
intent: {
colors: COLORS,
deadlineMs: 5_000,
...(options.projection ? { echoProjection: 'windows-conpty-esc-stripped' as const } : {})
deadlineMs: 5_000
},
...(options.projection ? { ownerBackend: 'windows-conpty' as const } : {}),
write: (data) => {
writes.push(data)
options.nested?.(data)
@ -33,26 +33,13 @@ function visible(emissions: readonly PtyIngressEmission[]): string {
describe('PtyStartupIngress', () => {
afterEach(() => vi.useRealTimers())
it('validates intent bounds and rejects a Windows projection on isolated hosts', () => {
it('validates intent colors and deadline bounds', () => {
const intent = {
colors: COLORS,
deadlineMs: 5_000,
echoProjection: 'windows-conpty-esc-stripped'
deadlineMs: 5_000
}
expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: true })).toEqual(
intent
)
expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: false })).toBe(
undefined
)
expect(
parsePtyStartupIngressIntent(
{ ...intent, deadlineMs: 30_001 },
{
allowWindowsEchoProjection: true
}
)
).toBeUndefined()
expect(parsePtyStartupIngressIntent(intent)).toEqual(intent)
expect(parsePtyStartupIngressIntent({ ...intent, deadlineMs: 30_001 })).toBeUndefined()
})
it('recognizes BEL/ST queries at every split and emits canonical replies', () => {
@ -146,6 +133,133 @@ describe('PtyStartupIngress', () => {
])
})
it('consumes a native ConPTY color query before any downstream responder at every split', () => {
const query = '\x1b]11;?\x1b\\'
for (let split = 0; split <= query.length; split += 1) {
const writes: string[] = []
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
ownerBackend: 'windows-conpty',
write: (data) => writes.push(data),
onEmission: (emission) => emissions.push(emission)
})
ingress.closeQueryAuthority()
ingress.accept(query.slice(0, split))
ingress.accept(query.slice(split))
ingress.drainAndClose()
expect(writes, `split ${split}`).toEqual([])
expect(visible(emissions), `split ${split}`).toBe('')
expect(emissions, `split ${split}`).toEqual([
{ data: '', rawStartSeq: 0, rawEndSeq: query.length, transformed: true }
])
}
})
it('keeps native ConPTY startup authority until it can answer with owner-supplied colors', () => {
const writes: string[] = []
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
intent: { colors: COLORS, deadlineMs: 5_000 },
ownerBackend: 'windows-conpty',
write: (data) => writes.push(data),
onEmission: (emission) => emissions.push(emission)
})
ingress.accept('\x1b]10;')
ingress.closeQueryAuthority()
ingress.accept('?\x07')
expect(writes).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\'])
expect(visible(emissions)).toBe('')
})
it('keeps a split native ConPTY query private across close, expiry, and snapshot barriers', () => {
vi.useFakeTimers()
for (const barrier of ['close', 'expire', 'snapshot'] as const) {
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
...(barrier === 'expire' ? { intent: { colors: COLORS, deadlineMs: 5_000 } } : {}),
ownerBackend: 'windows-conpty',
write: () => {},
onEmission: (emission) => emissions.push(emission)
})
ingress.accept('\x1b]10;')
if (barrier === 'close') {
ingress.closeQueryAuthority()
} else if (barrier === 'expire') {
vi.advanceTimersByTime(5_000)
} else {
ingress.snapshotBarrier()
}
expect(emissions, barrier).toEqual([])
ingress.accept('?\x07')
expect(visible(emissions), barrier).toBe('')
expect(emissions, barrier).toEqual([
{ data: '', rawStartSeq: 0, rawEndSeq: '\x1b]10;?\x07'.length, transformed: true }
])
}
const malformedEmissions: PtyIngressEmission[] = []
const malformed = new PtyStartupIngress({
ownerBackend: 'windows-conpty',
write: () => {},
onEmission: (emission) => malformedEmissions.push(emission)
})
malformed.accept('\x1b]10;')
malformed.snapshotBarrier()
malformed.accept('not-a-query\x07')
expect(visible(malformedEmissions)).toBe('\x1b]10;not-a-query\x07')
})
it('releases a partial query immediately when source authority closes', () => {
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
intent: { colors: COLORS, deadlineMs: 5_000 },
ownerBackend: 'posix-pty',
write: () => {},
onEmission: (emission) => emissions.push(emission)
})
ingress.accept('\x1b]10;')
expect(emissions).toEqual([])
ingress.closeQueryAuthority()
expect(visible(emissions)).toBe('\x1b]10;')
})
it('keeps POSIX, WSL, malformed, and unrelated output unchanged', () => {
const input = 'typed\x1b[A\x1b]12;?\x1b\\\x1b]10;not-a-query\x07'
vi.useFakeTimers()
for (const ownerBackend of ['posix-pty', 'windows-wsl'] as const) {
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
ownerBackend,
write: () => {},
onEmission: (emission) => emissions.push(emission)
})
ingress.accept(`\x1b]10;?\x07${input}`)
expect(visible(emissions)).toBe(`\x1b]10;?\x07${input}`)
}
const writes: string[] = []
const emissions: PtyIngressEmission[] = []
const nativeIngress = new PtyStartupIngress({
intent: { colors: COLORS, deadlineMs: 5_000 },
ownerBackend: 'windows-conpty',
write: (data) => writes.push(data),
onEmission: (emission) => emissions.push(emission)
})
vi.advanceTimersByTime(5_001)
nativeIngress.accept(`${input}\x1b]10;?\x07`)
expect(writes).toEqual([])
expect(visible(emissions)).toBe(input)
})
it('ignores callbacks after teardown without recreating the raw sequence domain', () => {
const { ingress, emissions } = createHarness({ projection: true })
ingress.accept('\x1b]10;?\x07')

View File

@ -4,62 +4,25 @@ import {
type TerminalOscColorQuerySlot
} from './terminal-osc-color-reply'
import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
import type { PtyOwnerBackend } from './pty-owner-backend'
import {
combinePtyIngressSourceSpans,
slicePtyIngressSourceSpan,
type PtyIngressEmission,
type PtyIngressSourceSpan,
type PtyStartupIngressOperation,
type PtyStartupIngressOptions
} from './pty-startup-ingress-contract'
export {
PTY_STARTUP_INGRESS_VERSION,
parsePtyStartupIngressIntent
} from './pty-startup-ingress-intent'
export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
export type PtyIngressEmission = {
data: string
rawStartSeq: number
rawEndSeq: number
transformed: boolean
}
type PtyIngressSourceChunk = {
data: string
rawStartSeq: number
rawEndSeq: number
}
type PendingOperation =
| { kind: 'data'; chunk: PtyIngressSourceChunk }
| { kind: 'close-query' }
| { kind: 'snapshot' }
| { kind: 'teardown' }
| { kind: 'expire' }
type PendingSpan = PtyIngressSourceChunk
export type PtyStartupIngressOptions = {
intent?: PtyStartupIngressIntent
write: (data: string) => void
onEmission: (emission: PtyIngressEmission) => void
}
export type { PtyIngressEmission, PtyStartupIngressOptions } from './pty-startup-ingress-contract'
const MAX_QUERY_CANDIDATE_CHARS = 64
function spanSlice(span: PendingSpan, start: number, end = span.data.length): PendingSpan {
return {
data: span.data.slice(start, end),
rawStartSeq: span.rawStartSeq + start,
rawEndSeq: span.rawStartSeq + end
}
}
function combineSpans(first: PendingSpan | null, second: PendingSpan): PendingSpan {
if (!first) {
return second
}
return {
data: first.data + second.data,
rawStartSeq: first.rawStartSeq,
rawEndSeq: second.rawEndSeq
}
}
function projectedWindowsConptyReply(reply: string): string {
// Why: the native provider harness observes ConPTY's cooked echo with ESC removed.
return reply.replaceAll('\x1b', '')
@ -71,21 +34,23 @@ function projectedWindowsConptyReply(reply: string): string {
*/
export class PtyStartupIngress {
private readonly intent: PtyStartupIngressIntent | undefined
private readonly ownerBackend: PtyOwnerBackend
private readonly writeProvider: (data: string) => void
private readonly onEmission: (emission: PtyIngressEmission) => void
private readonly operations: PendingOperation[] = []
private readonly operations: PtyStartupIngressOperation[] = []
private readonly answeredSlots = new Set<TerminalOscColorQuerySlot>()
private readonly expectedEchoes: string[] = []
private processing = false
private closed = false
private queryOpen: boolean
private rawHighWater = 0
private queryPending: PendingSpan | null = null
private echoPending: PendingSpan | null = null
private queryPending: PtyIngressSourceSpan | null = null
private echoPending: PtyIngressSourceSpan | null = null
private deadlineTimer: ReturnType<typeof setTimeout> | null = null
constructor(options: PtyStartupIngressOptions) {
this.intent = options.intent
this.ownerBackend = options.ownerBackend ?? 'posix-pty'
this.writeProvider = options.write
this.onEmission = options.onEmission
this.queryOpen = options.intent !== undefined
@ -129,7 +94,7 @@ export class PtyStartupIngress {
return this.rawHighWater
}
private enqueue(operation: PendingOperation): void {
private enqueue(operation: PtyStartupIngressOperation): void {
if (this.closed) {
return
}
@ -139,7 +104,7 @@ export class PtyStartupIngress {
}
this.processing = true
try {
let next: PendingOperation | undefined
let next: PtyStartupIngressOperation | undefined
while ((next = this.operations.shift())) {
this.applyOperation(next)
}
@ -148,18 +113,24 @@ export class PtyStartupIngress {
}
}
private applyOperation(operation: PendingOperation): void {
private applyOperation(operation: PtyStartupIngressOperation): void {
switch (operation.kind) {
case 'data':
this.processEchoSpan(operation.chunk)
return
case 'close-query':
this.queryOpen = false
this.releaseQueryPending()
if (this.ownerBackend !== 'windows-conpty') {
this.queryOpen = false
this.releaseQueryPending()
}
// Why: ConPTY cannot safely transfer color-query authority to a downstream view.
return
case 'expire':
this.queryOpen = false
this.releaseAllPending()
this.releaseEchoPending()
if (this.ownerBackend !== 'windows-conpty') {
this.releaseQueryPending()
}
this.expectedEchoes.length = 0
this.clearDeadline()
return
@ -175,8 +146,8 @@ export class PtyStartupIngress {
}
}
private processEchoSpan(span: PendingSpan): void {
let input = combineSpans(this.echoPending, span)
private processEchoSpan(span: PtyIngressSourceSpan): void {
let input = combinePtyIngressSourceSpans(this.echoPending, span)
this.echoPending = null
while (this.expectedEchoes.length > 0) {
@ -197,8 +168,8 @@ export class PtyStartupIngress {
}
this.expectedEchoes.shift()
this.emit(spanSlice(input, 0, expected.length), true, '')
input = spanSlice(input, expected.length)
this.emit(slicePtyIngressSourceSpan(input, 0, expected.length), true, '')
input = slicePtyIngressSourceSpan(input, expected.length)
if (input.data.length === 0) {
return
}
@ -207,32 +178,33 @@ export class PtyStartupIngress {
this.processQuerySpan(input)
}
private processQuerySpan(span: PendingSpan): void {
const input = combineSpans(this.queryPending, span)
private processQuerySpan(span: PtyIngressSourceSpan): void {
const input = combinePtyIngressSourceSpans(this.queryPending, span)
this.queryPending = null
if (!this.queryOpen || !this.intent) {
const suppressConptyQuery = this.ownerBackend === 'windows-conpty'
if ((!this.queryOpen || !this.intent) && !suppressConptyQuery) {
this.emit(input, false)
return
}
let offset = 0
while (offset < input.data.length) {
const candidateIndex = input.data.indexOf('\x1b', offset)
let scanOffset = 0
let emittedOffset = 0
while (scanOffset < input.data.length) {
const candidateIndex = input.data.indexOf('\x1b', scanOffset)
if (candidateIndex === -1) {
this.emit(spanSlice(input, offset), false)
this.emit(slicePtyIngressSourceSpan(input, emittedOffset), false)
return
}
if (candidateIndex > offset) {
this.emit(spanSlice(input, offset, candidateIndex), false)
}
const query = parseTerminalOscColorQuery(input.data, candidateIndex)
if (query.kind === 'none') {
this.emit(spanSlice(input, candidateIndex, candidateIndex + 1), false)
offset = candidateIndex + 1
scanOffset = candidateIndex + 1
continue
}
if (query.kind === 'partial') {
const candidate = spanSlice(input, candidateIndex)
if (candidateIndex > emittedOffset) {
this.emit(slicePtyIngressSourceSpan(input, emittedOffset, candidateIndex), false)
}
const candidate = slicePtyIngressSourceSpan(input, candidateIndex)
if (candidate.data.length <= MAX_QUERY_CANDIDATE_CHARS) {
this.queryPending = candidate
} else {
@ -241,13 +213,18 @@ export class PtyStartupIngress {
return
}
const querySpan = spanSlice(input, candidateIndex, query.endIndex)
if (!this.answerQuery(query.slots)) {
this.emit(querySpan, false)
} else {
this.emit(querySpan, true, '')
if (candidateIndex > emittedOffset) {
this.emit(slicePtyIngressSourceSpan(input, emittedOffset, candidateIndex), false)
}
offset = query.endIndex
const querySpan = slicePtyIngressSourceSpan(input, candidateIndex, query.endIndex)
const answered = this.queryOpen && this.intent && this.answerQuery(query.slots)
if (answered || suppressConptyQuery) {
this.emit(querySpan, true, '')
} else {
this.emit(querySpan, false)
}
scanOffset = query.endIndex
emittedOffset = query.endIndex
}
}
@ -268,9 +245,7 @@ export class PtyStartupIngress {
}
this.answeredSlots.add(slot)
const projected =
this.intent.echoProjection === 'windows-conpty-esc-stripped'
? projectedWindowsConptyReply(reply)
: null
this.ownerBackend === 'windows-conpty' ? projectedWindowsConptyReply(reply) : null
if (projected) {
// Why: register before write because node-pty can synchronously re-enter onData.
this.expectedEchoes.push(projected)
@ -303,25 +278,30 @@ export class PtyStartupIngress {
}
private releaseAllPending(): void {
const pending = this.echoPending ?? this.queryPending
this.echoPending = null
this.queryPending = null
if (pending) {
this.emit(pending, false)
this.releaseEchoPending()
this.releaseQueryPending()
}
private releaseEchoPending(): void {
if (!this.echoPending) {
return
}
const pending = this.echoPending
this.echoPending = null
this.emit(pending, false)
}
private releaseSnapshotPending(): void {
if (this.echoPending) {
const pending = this.echoPending
this.echoPending = null
this.expectedEchoes.shift()
this.emit(pending, false)
this.releaseEchoPending()
}
if (this.ownerBackend !== 'windows-conpty') {
this.releaseQueryPending()
}
this.releaseQueryPending()
}
private emit(span: PendingSpan, transformed: boolean, data = span.data): void {
private emit(span: PtyIngressSourceSpan, transformed: boolean, data = span.data): void {
this.onEmission({
data,
rawStartSeq: span.rawStartSeq,