Fix Windows ConPTY OSC color reply leaks at the PTY owner (#9651)
This commit is contained in:
parent
ede57cf723
commit
cc44acaaa3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue