From cc44acaaa361cde67be05b26cd04086e7d942753 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 23:50:25 -0400 Subject: [PATCH] Fix Windows ConPTY OSC color reply leaks at the PTY owner (#9651) --- ...onpty-startup-query-and-focus-authority.md | 50 ++++-- ...n-foreground-confirmation-protocol.test.ts | 3 +- src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-adapter.test.ts | 5 +- src/main/daemon/daemon-server.test.ts | 3 + src/main/daemon/daemon-server.ts | 14 +- src/main/daemon/session.test.ts | 58 ++++++- src/main/daemon/session.ts | 3 + .../terminal-host-pty-owner-backend.test.ts | 104 +++++++++++ src/main/daemon/terminal-host.ts | 6 + src/main/ipc/pty.ts | 12 +- src/main/providers/local-pty-provider.test.ts | 45 ++++- src/main/providers/local-pty-provider.ts | 6 + src/main/providers/ssh-pty-provider.test.ts | 6 +- src/main/runtime/orca-runtime.test.ts | 36 ++++ src/main/runtime/orca-runtime.ts | 7 + src/main/runtime/rpc/methods/terminal.ts | 9 + src/main/runtime/runtime-rpc.test.ts | 6 + .../runtime/terminal-view-attribute-store.ts | 15 ++ src/relay/pty-handler.test.ts | 142 ++++++++++++--- src/relay/pty-handler.ts | 16 +- .../remote-runtime-pty-transport.test.ts | 6 +- .../remote-runtime-pty-transport.ts | 2 + src/shared/pty-owner-backend.test.ts | 18 ++ src/shared/pty-owner-backend.ts | 24 +++ src/shared/pty-startup-ingress-contract.ts | 55 ++++++ src/shared/pty-startup-ingress-intent.ts | 18 +- src/shared/pty-startup-ingress.test.ts | 152 ++++++++++++++-- src/shared/pty-startup-ingress.ts | 164 ++++++++---------- 29 files changed, 798 insertions(+), 193 deletions(-) create mode 100644 src/main/daemon/terminal-host-pty-owner-backend.test.ts create mode 100644 src/shared/pty-owner-backend.test.ts create mode 100644 src/shared/pty-owner-backend.ts create mode 100644 src/shared/pty-startup-ingress-contract.ts diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md index 1a0c9922a..e7b13f0fb 100644 --- a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md +++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md @@ -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 diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 028fb8f0c..8e8a483df 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -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) }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 119a3bb14..6b5dfe8c3 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -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 { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9b022a442..eb9d5d275 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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' diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index bf7077ee7..b6588e44e 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -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 () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 8d529911c..9da8c2e48 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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()) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index b5446524e..1f709330e 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -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', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index c88061403..67a51d6a3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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) }) diff --git a/src/main/daemon/terminal-host-pty-owner-backend.test.ts b/src/main/daemon/terminal-host-pty-owner-backend.test.ts new file mode 100644 index 000000000..446e0923f --- /dev/null +++ b/src/main/daemon/terminal-host-pty-owner-backend.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index ea1093572..e6e0eb011 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -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 } : {}), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c14ba872c..4f2e0fa1b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 8609d97d1..94b657f39 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9eff8feea..276d0dd42 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 }) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 4a2bf0254..cac21f2fe 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -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 @@ -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 + }) ) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 27cdc239b..55a513ffc 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c7271fa7f..8fc9500c5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4be8d98c9..83f550c75 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index baa6ad47e..2c5d069b9 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index 2743dd70e..be33324d8 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -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 diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index d72422340..36881b244 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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 () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index ebdb7cea9..95a95023a 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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 + 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 } : {}) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index ea7e6dae4..f5ef55172 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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' } }) }) ) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index f5edb5ab6..7c2f8d4d4 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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, diff --git a/src/shared/pty-owner-backend.test.ts b/src/shared/pty-owner-backend.test.ts new file mode 100644 index 000000000..cda932e69 --- /dev/null +++ b/src/shared/pty-owner-backend.test.ts @@ -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) + }) +}) diff --git a/src/shared/pty-owner-backend.ts b/src/shared/pty-owner-backend.ts new file mode 100644 index 000000000..cbd27e09f --- /dev/null +++ b/src/shared/pty-owner-backend.ts @@ -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' +} diff --git a/src/shared/pty-startup-ingress-contract.ts b/src/shared/pty-startup-ingress-contract.ts new file mode 100644 index 000000000..c8e865faa --- /dev/null +++ b/src/shared/pty-startup-ingress-contract.ts @@ -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 + } +} diff --git a/src/shared/pty-startup-ingress-intent.ts b/src/shared/pty-startup-ingress-intent.ts index 8b58c1183..8fed61ee0 100644 --- a/src/shared/pty-startup-ingress-intent.ts +++ b/src/shared/pty-startup-ingress-intent.ts @@ -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 } } diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts index 55268f04f..a6ec33a37 100644 --- a/src/shared/pty-startup-ingress.test.ts +++ b/src/shared/pty-startup-ingress.test.ts @@ -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') diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index 56042a6c5..0675eb92a 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -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() 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 | 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,