diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index ba3e64bfa2f..d9ee133a70e 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -9,9 +9,9 @@ // // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where -// upstream already destroys the input socket. Two hidden rate-limit probes -// (`src/main/rate-limits/claude-pty.ts`, `codex-pty-rate-limit-probe.ts`) do omit the option and so -// do run this hunk, but no user-visible pane does. The divergence pinned below is about which +// upstream already destroys the input socket. The hidden rate-limit probe +// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no +// user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index cd47d038c2a..2d41bdc4412 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -107,6 +107,7 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/claude/statusline-script.ts", + "../src/main/claude/windows-hook-files.ts", "../src/main/claude-accounts/keychain.ts", "../src/main/macos-keychain/generic-password.ts", "../src/main/codex/codex-app-server-capability-cache.ts", diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index f1e63657f76..edb3a296337 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -274,7 +274,7 @@ _shorten the interpreter chain_ rather than to hide a window. #18875 is a worked example of that doctrine. The Claude Code lifecycle hook was registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire decoded payload was a `Test-Path` and a call to `~/.orca/agent-hooks/claude-hook.cmd`. -It now registers the script path itself (` || echo {}`), so `bash -> +It now registers the bare script path itself, with no shell operators, so `bash -> powershell -> cmd -> curl` became `bash -> cmd -> curl` and one `powershell.exe -EncodedCommand` per hook event — a first-class Defender alert title — leaves the tree. The reporting box fired ~6 900 of them in five days, @@ -287,24 +287,24 @@ concurrency, invoked as Claude Code invokes it. **No EDR verdict on either tree was measured**, so claim the removed `-EncodedCommand` spelling and the shorter chain, not a score. `cmd.exe` remains in the tree, spelled by MSYS's own `.cmd` spawn rather than by us — the doc's one "unavoidable for `.cmd`/`.bat`" case, -carrying an absolute path and two literal tokens, with no caret escaping, no +carrying only an absolute path, with no caret escaping, no encoding and no free text. The encoded launcher is still the shape for profile paths the shells cannot carry bare (a space, `%`, `^`, `&`, non-ASCII, a UNC -profile) and for hosts where Git Bash is not resolvable, because PowerShell 5.1 -rejects `||` (measured: parse error, exit 1). +profile). -That last clause is the standing assumption of this change, and it is worth -stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe -and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for -any host that is one of the first three. Claude Code itself is a Git Bash host on -native Windows. What no one here has verified is which host a _compat consumer_ -uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command` -through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip -for exactly that). If one of them spawns hook strings through Windows PowerShell -5.1, its imported Claude events become a parse error with empty stdout, which is -the fail-closed case #14818 exists to prevent. The encoded launcher had no such -assumption — it was a `powershell.exe` invocation and therefore parsed anywhere. -Before widening the direct shape to another agent, measure that consumer's host. +The direct shape first shipped as ` || echo {}`, gated on Git Bash being +resolvable. That gate guessed at a choice Claude Code makes on its own: it runs +hooks under Windows PowerShell 5.1 when it picks PowerShell, and 5.1 rejects `||` +(parse error, exit 1), so every hook failed (STA-8913). The registered command is +now the bare path, which parses in Git Bash, cmd.exe, pwsh and PowerShell 5.1. +The neutral `{}` for a missing payload lives in the `claude-hook.cmd` entry, which +hands off to `claude-hook-impl.cmd` in the same `cmd.exe`; a deleted entry is an +ordinary non-blocking hook error (never exit 2) until the next install rewrites it. +Compat consumers such as cursor-agent and Devin import `~/.claude/settings.json` +and run `command` through their own launcher (the payload carries a +`DEVIN_PROJECT_DIR` skip for that); one that cannot start a `.cmd` at all still +gets a launch failure. Before widening the direct shape to another agent, measure +that consumer's host. ### Computer use: screen capture, synthetic input, runtime-compiled MSIL diff --git a/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts new file mode 100644 index 00000000000..3ea7171c6bb --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: null, + source: 'transcript' +} + +// An old host still sends Claude's raw paste envelope; the shared normalizer must match it. +it('retires an echo against an old-host wrapped row and keeps the second identical send', () => { + const text = 'one\ntwo' + const echo: NativeChatMessage = { + ...boundary, + id: 'user', + role: 'user', + blocks: [{ type: 'text', text: `\n${text}\n` }] + } + const reply: NativeChatMessage = { ...boundary, id: 'reply' } + const entry = { + id: 'one', + text, + expectedOccurrence: 1, + baselineTailMessageId: 'boundary', + baselineResolved: true + } + expect( + retireLandedMobileNativeChatPending( + [boundary, echo, reply], + [entry, { ...entry, id: 'two', expectedOccurrence: 2 }], + new Set() + ).map((item) => item.id) + ).toEqual(['two']) +}) diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 0095a3275ce..e63643d0d41 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -78,6 +78,8 @@ export type TerminalDocumentState = { lastEmittedModes: TerminalDocumentModes /** `terminal-init`: whether the terminal has ever reached ready. */ everReady: boolean + /** `mouse-mode-decset-scan`: whether the encoding is proven (1006/1016 set or reset, RIS, or a live tracking enable). */ + mouseEncodingKnown: boolean /** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */ mouseModeScanTail: string /** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */ @@ -262,6 +264,7 @@ function createTerminalDocumentState(): TerminalDocumentState { sgrMousePixelsMode: false }, everReady: false, + mouseEncodingKnown: false, mouseModeScanTail: '', trackedMouseTrackingMode: 'none', sgrMouseMode: false, diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index 0b155e1b70c..3d5352b3962 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -94,6 +94,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage scope.statusDotPendingSelector = false scope.afterDrainCallbacks = [] scope.writesDraining = false + scope.mouseEncodingKnown = false scope.mouseModeScanTail = '' scope.trackedMouseTrackingMode = 'none' scope.sgrMouseMode = false diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index 15af53d8646..dbae42668d4 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -24,7 +24,7 @@ export type TerminalMouseGesture = { // One report per transition, built with the same encoding ladder as // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' // when the mode does not report this transition (x10 has no release, only -// drag/any report motion) or the cell is not encodable. +// drag/any report motion), the encoding is unproven, or the cell is not encodable. export function buildMouseButtonReport( scope: TerminalDocumentScope, kind: string, @@ -32,7 +32,7 @@ export function buildMouseButtonReport( clientY: number ) { const mouseTrackingMode = getMouseTrackingMode(scope) - if (mouseTrackingMode === 'none') { + if (mouseTrackingMode === 'none' || !scope.mouseEncodingKnown) { return '' } if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') { @@ -103,7 +103,8 @@ export function abandonMouseGesture(scope: TerminalDocumentScope) { export function beginMouseDrag(scope: TerminalDocumentScope, gesture: TerminalMouseGesture) { gesture.moved = true - if (getMouseTrackingMode(scope) !== 'none') { + // Why: with no proven encoding the program would get no reports, so select locally instead. + if (getMouseTrackingMode(scope) !== 'none' && scope.mouseEncodingKnown) { gesture.mode = 'tracking' gesture.lastCellKey = mouseReportCellKey(scope, gesture.startX, gesture.startY) const press = buildMouseButtonReport(scope, 'press', gesture.startX, gesture.startY) diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts index 8b6c8665c29..00b8988f903 100644 --- a/mobile/src/terminal/document/mouse-input-encoding.ts +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -61,6 +61,9 @@ export function buildMouseWheelSequence( clientX: number, clientY: number ) { + if (!scope.mouseEncodingKnown) { + return '' + } const cell = viewportToMouseReportCell(scope, clientX, clientY) if (!cell) { return '' @@ -105,7 +108,7 @@ export function buildMouseClickInput( clientY: number ) { const mouseTrackingMode = getMouseTrackingMode(scope) - if (!isClickMouseTrackingMode(mouseTrackingMode)) { + if (!isClickMouseTrackingMode(mouseTrackingMode) || !scope.mouseEncodingKnown) { return '' } const cell = viewportToMouseReportCell(scope, clientX, clientY) @@ -168,8 +171,18 @@ export function isWheelMouseTrackingMode(mode: string) { return mode !== 'none' && mode !== 'x10' } +// Why: a replay can carry tracking without its encoding (?1006h/?1016h); a guessed +// legacy report would type `ESC[M` bytes into the program, so scroll locally instead. +export function isWheelEncodingUnproven(scope: TerminalDocumentScope, mode: string) { + return isWheelMouseTrackingMode(mode) && !scope.mouseEncodingKnown +} + export function shouldRouteScrollToTerminalInput(scope: TerminalDocumentScope) { - return isWheelMouseTrackingMode(getMouseTrackingMode(scope)) || isAlternateBufferActive(scope) + const mode = getMouseTrackingMode(scope) + if (isWheelEncodingUnproven(scope, mode)) { + return false + } + return isWheelMouseTrackingMode(mode) || isAlternateBufferActive(scope) } export function buildMouseWheelScrollInput( @@ -221,6 +234,10 @@ export function routeScrollLines( } const mouseTrackingMode = getMouseTrackingMode(scope) const alternateBufferActive = isAlternateBufferActive(scope) + if (isWheelEncodingUnproven(scope, mouseTrackingMode)) { + scope.term.scrollLines(lines) + return + } if (isWheelMouseTrackingMode(mouseTrackingMode)) { // Why: xterm sends wheel events to mouse-aware TUIs before considering // scrollback, even if the app stays on the normal buffer. diff --git a/mobile/src/terminal/document/mouse-mode-decset-scan.ts b/mobile/src/terminal/document/mouse-mode-decset-scan.ts index 05490ce0b0f..8850db110c9 100644 --- a/mobile/src/terminal/document/mouse-mode-decset-scan.ts +++ b/mobile/src/terminal/document/mouse-mode-decset-scan.ts @@ -22,7 +22,8 @@ export function normalizeInitialData(data: unknown) { return on > 0 ? data.slice(on) : data } -export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown) { +// `live`: bytes the program just wrote, as opposed to a snapshot replay. +export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown, live = false) { if (typeof data !== 'string' || data.length === 0) { return } @@ -35,6 +36,7 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn let match: RegExpExecArray | null while ((match = re.exec(input)) !== null) { if (match[0] === ESC + 'c') { + scope.mouseEncodingKnown = true scope.trackedMouseTrackingMode = 'none' scope.sgrMouseMode = false scope.sgrMousePixelsMode = false @@ -50,6 +52,11 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn if (!Number.isInteger(param)) { continue } + // Why: a program enabling tracking live states its encoding in that same burst, or + // means the default; only a replay can carry tracking without its encoding. + if (live && enabled && (param === 9 || param === 1000 || param === 1002 || param === 1003)) { + scope.mouseEncodingKnown = true + } if (param === 9) { scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none' } @@ -63,10 +70,12 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn scope.trackedMouseTrackingMode = enabled ? 'any' : 'none' } if (param === 1006) { + scope.mouseEncodingKnown = true scope.sgrMouseMode = enabled scope.sgrMousePixelsMode = false } if (param === 1016) { + scope.mouseEncodingKnown = true scope.sgrMouseMode = false scope.sgrMousePixelsMode = enabled } diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts index a271cd97941..8c17eba19ec 100644 --- a/mobile/src/terminal/document/surface-tap.ts +++ b/mobile/src/terminal/document/surface-tap.ts @@ -59,7 +59,12 @@ export function notifyTerminalSurfaceTap( notify(scope, { type: 'terminal-input', bytes: clickInput }) } // Touch still needs native input focus after the TUI consumes its mouse click. - if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode(scope))) { + // A click with an unproven encoding reached no program, so it acts as a plain tap. + if ( + focusKeyboard || + !isClickMouseTrackingMode(getMouseTrackingMode(scope)) || + !scope.mouseEncodingKnown + ) { notify(scope, { type: 'terminal-tap' }) } } diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index 04e90b4578e..091321503b6 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -122,6 +122,7 @@ export function init( scope.wheelAccumDeltaY = 0 scope.mouseModeScanTail = '' scope.trackedMouseTrackingMode = 'none' + scope.mouseEncodingKnown = false scope.sgrMouseMode = false scope.sgrMousePixelsMode = false scope.lastEmittedModes = { @@ -208,7 +209,7 @@ export function init( } export function write(scope: TerminalDocumentScope, data: string) { - updateMouseModeFromData(scope, data) + updateMouseModeFromData(scope, data, true) enqueueWrite(scope, data) pumpWrites(scope, scope.terminalGeneration) // Why: first live data chunk after init may widen the buffer past diff --git a/mobile/src/terminal/terminal-mouse-authority.test.ts b/mobile/src/terminal/terminal-mouse-authority.test.ts new file mode 100644 index 00000000000..0ea7df16dd2 --- /dev/null +++ b/mobile/src/terminal/terminal-mouse-authority.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness' + +type DocumentMessage = { + type: string + data?: string + cols?: number + rows?: number + initialData?: string +} + +function message(data: DocumentMessage) { + window.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) })) +} + +function wheel(deltaY = 120) { + document.getElementById('terminal-surface')!.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + clientX: 40, + clientY: 60 + }) + ) +} + +function swipe() { + const surface = document.getElementById('terminal-surface')! + for (const [type, y] of [ + ['touchstart', 240], + ['touchmove', 120], + ['touchend', 120] + ] as const) { + const event = new Event(type, { bubbles: true, cancelable: true }) + const touch = { identifier: 1, clientX: 40, clientY: y } + Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [touch] }) + Object.defineProperty(event, 'changedTouches', { value: [touch] }) + surface.dispatchEvent(event) + } +} + +function touchTap() { + const surface = document.getElementById('terminal-surface')! + for (const type of ['touchstart', 'touchend']) { + const event = new Event(type, { bubbles: true, cancelable: true }) + const touches = type === 'touchend' ? [] : [{ identifier: 0, clientX: 40, clientY: 60 }] + Object.defineProperty(event, 'touches', { + value: touches.map((touch) => ({ ...touch, target: surface })) + }) + Object.defineProperty(event, 'target', { value: surface }) + document.dispatchEvent(event) + } +} + +// What a desktop pane snapshot looks like: normal-buffer scrollback, the alternate screen, the +// tracking modes the serializer writes, then the encoding the pane appends at the very end. +const PANE_PREFIX = `scrollback\r\n${ESC}[?1049h${ESC}[H codex prompt${ESC}[?1003h` + +describe('mobile mouse encoding proof', () => { + const mouse = useTerminalMouseWebViewHarness() + + function bootAltScreen(initialData: string, mode = 'any') { + mouse.boot(initialData) + mouse.showAlternateBuffer() + mouse.activeTerminal().modes.mouseTrackingMode = mode + mouse.clearPostedMessages() + } + + it('reports a swipe and a wheel as SGR from a pane snapshot that ends with ?1006h', () => { + bootAltScreen(`${PANE_PREFIX}${ESC}[?1006h`) + swipe() + wheel(-120) + const bytes = mouse.terminalInputBytes() + expect(bytes).toContain(`${ESC}[<64;`) + wheel() + expect(mouse.terminalInputBytes()).toContain(`${ESC}[<65;`) + expect(mouse.terminalInputBytes()).not.toContain(`${ESC}[M`) + }) + + it('loses an encoding that sits before the alternate screen the phone replays from', () => { + bootAltScreen(`${ESC}[?1006h${PANE_PREFIX}`) + wheel() + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('sends nothing for a wheel, a swipe or a tap while tracking has no proven encoding', () => { + bootAltScreen(PANE_PREFIX) + wheel() + swipe() + touchTap() + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.postedMessages().filter((posted) => posted.type === 'terminal-tap')).toHaveLength( + 1 + ) + }) + + it.each([ + ['SGR', '?1006h', '[<65;'], + ['pixel', '?1016h', '[<65;'], + ['explicit legacy', '?1006l', '[Ma'] + ])('keeps %s encoding proven by a live write', (_name, mode, prefix) => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'any' + message({ type: 'write', data: `${ESC}[${mode}${ESC}[?1003h` }) + wheel() + expect(mouse.terminalInputBytes()).toContain(`${ESC}${prefix}`) + }) + + it('reports legacy wheel input from a pane snapshot that states the default encoding', () => { + bootAltScreen(`${PANE_PREFIX}${ESC}[?1006l`) + wheel() + expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`) + }) + + it('reports legacy wheel input for a program that enables tracking live without an encoding', () => { + mouse.boot() + message({ type: 'write', data: `${ESC}[?1049h${ESC}[?1000h` }) + mouse.showAlternateBuffer() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + wheel() + expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`) + }) + + it('forgets encoding proof on the next snapshot', () => { + mouse.boot() + message({ type: 'write', data: `${ESC}[?1006l` }) + message({ type: 'init', cols: 40, rows: 24, initialData: `${ESC}[?1003h` }) + mouse.activeTerminal().modes.mouseTrackingMode = 'any' + wheel() + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('keeps arrow-key scrolling for a click-only (x10) program on the alternate screen', () => { + bootAltScreen(`${ESC}[?1049h${ESC}[?9h`, 'x10') + wheel(-120) + swipe() + const bytes = mouse.terminalInputBytes() + expect(bytes).toContain(`${ESC}[B`) + expect(bytes).toContain(`${ESC}[A`) + expect(bytes).not.toContain(`${ESC}[M`) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts index 4c519deeef1..97056437468 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -7,6 +7,7 @@ describe('terminal WebView external mouse click', () => { it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => { mouse.boot() + mouse.writeLegacyMouseEncoding() mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' mouse.mouseClick(40, 60) @@ -21,6 +22,18 @@ describe('terminal WebView external mouse click', () => { expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) }) + it('sends no click report and focuses the keyboard while the encoding is unproven', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.mouseClick(40, 60) + + expect(mouse.terminalInputBytes()).toBe('') + expect( + mouse.postedMessages().filter((message) => message.type === 'terminal-tap') + ).toHaveLength(1) + }) + it('dismisses an existing selection with a click without focusing the keyboard', () => { mouse.boot() mouse.mouseDrag(40, 60, 160, 90) diff --git a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts index e4fac512873..aefbd3df325 100644 --- a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts @@ -11,6 +11,7 @@ describe('terminal WebView external mouse drag', () => { it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => { mouse.boot() + mouse.writeLegacyMouseEncoding() mouse.activeTerminal().modes.mouseTrackingMode = 'drag' mouse.mouseDrag(40, 60, 160, 60) @@ -29,6 +30,7 @@ describe('terminal WebView external mouse drag', () => { it('does not report motion or release for an x10 click-only TUI drag', () => { mouse.boot() + mouse.writeLegacyMouseEncoding() mouse.activeTerminal().modes.mouseTrackingMode = 'x10' mouse.mouseDrag(40, 60, 160, 60) @@ -53,8 +55,20 @@ describe('terminal WebView external mouse drag', () => { expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }]) }) + it('selects text instead of reporting a drag while the encoding is unproven', () => { + mouse.boot(`${ESC}[?1002h`) + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + mouse.clearPostedMessages() + + mouse.mouseDrag(40, 60, 160, 90) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.selectionSpy()).toHaveBeenCalled() + }) + it('releases a tracked drag when the button state shows the pointerup was lost', () => { mouse.boot() + mouse.writeLegacyMouseEncoding() mouse.activeTerminal().modes.mouseTrackingMode = 'drag' mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) @@ -74,6 +88,7 @@ describe('terminal WebView external mouse drag', () => { it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { mouse.boot() + mouse.writeLegacyMouseEncoding() mouse.activeTerminal().modes.mouseTrackingMode = 'drag' mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) diff --git a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts index 41a6b2b7a62..6342b88d1f8 100644 --- a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts +++ b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts @@ -150,12 +150,12 @@ export function useTerminalMouseWebViewHarness() { let select: Select let terminals: TerminalStub[] - function boot(): void { + function boot(initialData = ''): void { document.body.innerHTML = bodyMarkup() runInThisContext(TERMINAL_DOCUMENT_SCRIPT) window.dispatchEvent( new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) + data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData }) }) ) // Why: init commits the replacement surface on the next animation frame. @@ -164,6 +164,15 @@ export function useTerminalMouseWebViewHarness() { } } + // Why: the document sends no mouse report until replayed bytes prove the encoding. + function writeLegacyMouseEncoding(): void { + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` }) + }) + ) + } + function activeTerminal(): TerminalStub { const terminal = terminals.at(-1) if (!terminal) { @@ -231,6 +240,9 @@ export function useTerminalMouseWebViewHarness() { return { activeTerminal, boot, + showAlternateBuffer: () => { + buffer.type = 'alternate' + }, clearPostedMessages: () => postMessage.mockClear(), dispatchPointer, mouseClick, @@ -238,6 +250,7 @@ export function useTerminalMouseWebViewHarness() { postedMessages: () => postedMessages(postMessage), selectionSpy: () => select, terminalInputBytes: () => terminalInputBytes(postMessage), - terminalSurface + terminalSurface, + writeLegacyMouseEncoding } } diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index 0db190b738b..bc70b7ae52f 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -148,6 +148,11 @@ describe('terminal WebView tap routing', () => { it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { const { posted } = boot('interactive prompt', undefined, 'drag') await settle() + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) + }) + ) fireTouch('touchstart', [{ x: 20, y: tapY }]) fireTouch('touchend', []) @@ -159,6 +164,17 @@ describe('terminal WebView tap routing', () => { ).toEqual(['terminal-input', 'terminal-tap']) }) + it('focuses native input without mouse bytes while the tracking encoding is unproven', async () => { + const { posted } = boot('interactive prompt', undefined, 'drag') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() + expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + }) + it('reports a non-mouse touch tap without terminal mouse bytes', async () => { const { posted } = boot('plain prompt') await settle() diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts b/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts index decbc170720..f5e6be8fdb5 100644 --- a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts +++ b/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts @@ -218,6 +218,11 @@ describe('terminal WebView external pointer wheel scrolling', () => { throw new Error('terminal missing') } terminal.modes.mouseTrackingMode = 'any' + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` }) + }) + ) dispatchWheel(CELL_HEIGHT) diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index ebb1412ec58..04eb500c218 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -67,11 +67,7 @@ const buildersByAgent = new Map([ [ 'claude', { - local: (path) => - [true, false].map( - (gitBashAvailable) => - getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS, { gitBashAvailable }).command - ), + local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command], remote: (path) => [getClaudeRemoteCommand(path)] } ], diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts index dabbb93374c..ff21f8519bb 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -69,4 +69,21 @@ describe('managed hook script refresh stays off the main thread', () => { expect(syncCallsUnderHome()).toEqual([]) }) + + it('keeps the Windows Claude entry and payload refresh off the main thread', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const hooksDir = join(state.home, '.orca', 'agent-hooks') + await mkdir(hooksDir, { recursive: true }) + await writeFile(join(hooksDir, 'claude-hook.cmd'), 'stale', 'utf-8') + state.syncCalls = [] + + await MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.find(([agent]) => agent === 'claude')![1]() + + expect(syncCallsUnderHome()).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) }) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts index 799f8acf58c..4cd1905d8aa 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -40,6 +40,10 @@ const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) +vi.mock('../codex/codex-hook-trust-grant', () => ({ + grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' }) +})) + vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts index 5842a9dfbfe..4de9f3ab24c 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { grantDirAclAsync, isPermissionError } from '../win32-utils' @@ -28,7 +28,7 @@ async function readExistingScript(scriptPath: string): Promise { } } -async function scriptStillExists(scriptPath: string): Promise { +export async function scriptStillExists(scriptPath: string): Promise { try { await stat(scriptPath) return true @@ -61,12 +61,26 @@ async function writeScriptWithAclRetry(scriptPath: string, content: string): Pro export async function refreshManagedScriptIfPresent( scriptPath: string, content: string +): Promise { + return writeManagedScriptAtomically(scriptPath, content, false) +} + +// Callers must establish ownership from a surviving managed script or registration first. +export async function restoreManagedScript(scriptPath: string, content: string): Promise { + await mkdir(dirname(scriptPath), { recursive: true }) + await writeManagedScriptAtomically(scriptPath, content, true) +} + +async function writeManagedScriptAtomically( + scriptPath: string, + content: string, + allowMissing: boolean ): Promise { const existing = await readExistingScript(scriptPath) - if (!existing.exists) { + if (!existing.exists && !allowMissing) { return false } - if (existing.content === content) { + if (existing.exists && existing.content === content) { if (process.platform !== 'win32') { await chmod(scriptPath, 0o755) } @@ -79,7 +93,7 @@ export async function refreshManagedScriptIfPresent( if (process.platform !== 'win32') { await chmod(tmpPath, 0o755) } - if (!(await scriptStillExists(scriptPath))) { + if (!allowMissing && !(await scriptStillExists(scriptPath))) { return false } await rename(tmpPath, scriptPath) diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index cd6c336e751..ff7602b0689 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -34,6 +34,10 @@ const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) +vi.mock('../codex/codex-hook-trust-grant', () => ({ + grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' }) +})) + vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' @@ -296,7 +300,7 @@ describe('Windows managed hook stdin structure', () => { expect(script, `${fileName} no ORCA_* guard may route to the more.com drain`).not.toMatch( /ORCA_[A-Z_]+.*goto :?orca_agent_hook_drain_stdin/ ) - // Why: the epilogue stays shared — claude-hook.cmd still jumps to it from the + // Why: the epilogue stays shared — claude-hook-impl.cmd still jumps to it from the // Devin-imports-.claude skip, which now sits below these guards. expect(script, `${fileName} drain epilogue`).toContain( [ @@ -310,7 +314,7 @@ describe('Windows managed hook stdin structure', () => { // Why (#11549): the Devin skip is the only remaining in-script jump to more.com, so it // must sit below the env guards — otherwise a Devin session outside an Orca pane still // parks there and strands the hook exactly like the pre-fix guards did. - const claude = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') + const claude = readFileSync(join(hooksDir, 'claude-hook-impl.cmd'), 'utf8') expect(claude, 'claude devin guard present').toContain( 'if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin' ) @@ -524,7 +528,7 @@ describe('Windows managed hook stdin structure', () => { // Why: the encoded launcher resolves %USERPROFILE% at run time, so redirecting it is // what makes the script vanish for that shape. The direct launcher (#18875) carries // an absolute path, so here it asserts only that a bogus profile changes nothing; its - // missing-script fallback is covered live in windows-direct-cmd-hook-command.test.ts. + // missing-entry failure (never exit 2) is covered live in windows-direct-cmd-hook-command.test.ts. name: 'missing managed script', env: hookEnvironment({ USERPROFILE: absentProfile }) } diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts index acb4bf2d46d..d2acdb09acd 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts @@ -1,23 +1,23 @@ -// Why (#18875): the registered Windows Claude hook is now the script path itself, so this file -// pins the two things that make that safe — the shape carries nothing MSYS or cmd.exe rewrites, -// and it still answers with neutral JSON when the script is gone. The live legs run the string -// through BOTH hosts Claude Code can pick, because the shape has to parse in either. +// The registered command must not depend on the shell Claude selects. import { describe, expect, it } from 'vitest' -import { execFileSync } from 'node:child_process' +import { runProcess } from '../../shared/child-process/run-process' import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command' +import { getWindowsClaudeHookEntry } from '../claude/windows-hook-files' +import { getManagedScript } from '../claude/hook-script' +import { getWindowsPowerShellExecutablePath } from './windows-powershell-hook-launcher' import { findGitBash } from './windows-git-bash-path.test-fixture' const SAFE_PATH = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' describe('wrapWindowsDirectCmdHookCommand', () => { - it('emits the script path with forward slashes and a neutral-JSON fallback', () => { + it('emits an operator-free path that PowerShell 5.1 can parse', () => { expect(wrapWindowsDirectCmdHookCommand(SAFE_PATH)).toBe( - 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}' + 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ) }) @@ -33,6 +33,7 @@ describe('wrapWindowsDirectCmdHookCommand', () => { // Why: `2>nul` writes a literal file named `nul` into the cwd under MSYS (measured), and no // stderr sink parses in both hosts. The missing-script line is left on stderr deliberately. expect(command).not.toContain('2>') + expect(command).not.toMatch(/[|&;]/) }) it('declines any path the shells cannot carry bare', () => { @@ -53,91 +54,142 @@ describe('wrapWindowsDirectCmdHookCommand', () => { }) }) -describe.skipIf(process.platform !== 'win32')('direct hook command, run by both hook hosts', () => { - // Why: the fixture throws when Git Bash is absent, and that is a skip here, not a failure — - // a box without it never gets this command shape in the first place. - const gitBash = ((): string | null => { - try { - return findGitBash() - } catch { - return null - } - })() - - function runInCmd(command: string, cwd: string): { stdout: string; status: number } { - return runCapture('cmd.exe', ['/d', '/c', command], cwd) - } - - function runInBash(command: string, cwd: string): { stdout: string; status: number } { - return runCapture(gitBash!, ['-c', command], cwd) - } - - function runCapture(file: string, args: string[], cwd: string) { - try { - const stdout = execFileSync(file, args, { - cwd, - input: '{"hook_event_name":"PreToolUse"}', - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] - }) - return { stdout, status: 0 } - } catch (error) { - const failure = error as { stdout?: string; status?: number } - return { stdout: failure.stdout ?? '', status: failure.status ?? 1 } - } - } - - // Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case, - // so these legs skip rather than assert a contract that shape never claimed. - const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd')) - const canRunLive = Boolean(gitBash) && tempIsCmdSafe - - function withTempDir(run: (dir: string, scriptPath: string, command: string) => void): void { - const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-')) - try { - const scriptPath = join(dir, 'claude-hook.cmd') - const command = wrapWindowsDirectCmdHookCommand(scriptPath) - expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull() - run(dir, scriptPath, command!) - } finally { - // Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on - // Windows while their handles drain. - removeTreeSync(dir) - } - } - - it.skipIf(!canRunLive)('answers {} and exit 0 in both hosts when the script exists', () => { - withTempDir((dir, scriptPath, command) => { - writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8') - for (const result of [runInCmd(command, dir), runInBash(command, dir)]) { - expect(result.stdout.trim()).toBe('{}') - expect(result.status).toBe(0) +describe.skipIf(process.platform !== 'win32')( + 'direct hook command, run by Windows hook hosts', + () => { + // Git Bash is optional; cmd.exe and Windows PowerShell still exercise the command without it. + const gitBash = ((): string | null => { + try { + return findGitBash() + } catch { + return null } - }) - }) + })() - it.skipIf(!canRunLive)( - 'still answers {} and exit 0 in both hosts when the script is gone', - () => { - // Why: compat consumers require neutral JSON even with no managed script (#14818). The - // encoded launcher did this with a Test-Path; `|| echo {}` does it with no interpreter. - withTempDir((dir, scriptPath, command) => { - expect(existsSync(scriptPath)).toBe(false) - for (const result of [runInCmd(command, dir), runInBash(command, dir)]) { - expect(result.stdout.trim()).toBe('{}') - expect(result.status).toBe(0) - } - }) + async function runInHosts(command: string, cwd: string) { + const hosts: [string, string[]][] = [ + ['cmd.exe', ['/d', '/c', command]], + [getWindowsPowerShellExecutablePath(), ['-NoProfile', '-Command', command]] + ] + if (gitBash) { + hosts.push([gitBash, ['-c', command]]) + } + const pwsh = join( + process.env.ProgramFiles ?? 'C:\\Program Files', + 'PowerShell', + '7', + 'pwsh.exe' + ) + if (existsSync(pwsh)) { + hosts.push([pwsh, ['-NoProfile', '-Command', command]]) + } + const results: Awaited>[] = [] + for (const [file, args] of hosts) { + results.push(await runCapture(file, args, cwd)) + } + return results } - ) - it.skipIf(!canRunLive)('leaves no stray `nul` file behind in the working directory', () => { - // Why this is worth a test: adding `2>nul` to silence the missing-script line looks like - // tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the - // user's repo. Measured on Windows 11. Keep stderr unredirected. - withTempDir((dir, _scriptPath, command) => { - runInBash(command, dir) - expect(readdirSync(dir)).not.toContain('nul') - }) - }) -}) + // Why async runProcess: runProcessSync cannot take a string stdin (it forces encoding 'buffer'). + async function runCapture(file: string, args: string[], cwd: string) { + const result = await runProcess({ + program: file, + args, + cwd, + // Why PATHEXT: without it Windows PowerShell 5.1 prints nothing and exits 0 for a .cmd path + // (measured); every real hook host inherits it. + env: { + SystemRoot: process.env.SystemRoot, + PATH: process.env.PATH, + PATHEXT: process.env.PATHEXT, + ComSpec: process.env.ComSpec, + HOME: cwd, + USERPROFILE: cwd + }, + input: '{"hook_event_name":"PreToolUse"}', + timeoutMs: 5_000 + }) + expect(result.timedOut, result.stderr).toBe(false) + return { stdout: result.stdout, status: result.code, label: `${file}: ${result.stderr}` } + } + + // Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case, + // so these legs skip rather than assert a contract that shape never claimed. + const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd')) + const canRunLive = tempIsCmdSafe + + async function withTempDir( + run: (dir: string, scriptPath: string, command: string) => Promise + ): Promise { + const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-')) + try { + const scriptPath = join(dir, 'claude-hook.cmd') + const command = wrapWindowsDirectCmdHookCommand(scriptPath) + expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull() + await run(dir, scriptPath, command!) + } finally { + // Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on + // Windows while their handles drain. + removeTreeSync(dir) + } + } + + it.skipIf(!canRunLive)( + 'answers {} and exit 0 in both hosts when the script exists', + async () => { + await withTempDir(async (dir, scriptPath, command) => { + writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8') + for (const result of await runInHosts(command, dir)) { + expect(result.stdout.trim(), result.label).toBe('{}') + expect(result.status, result.label).toBe(0) + } + }) + } + ) + + it.skipIf(!canRunLive)( + 'runs the generated pair and answers when its payload is missing', + async () => { + await withTempDir(async (dir, scriptPath, command) => { + writeFileSync(scriptPath, getWindowsClaudeHookEntry(), 'utf8') + for (const result of await runInHosts(command, dir)) { + expect(result.stdout.trim(), result.label).toBe('{}') + expect(result.status, result.label).toBe(0) + } + writeFileSync(join(dir, 'claude-hook-impl.cmd'), getManagedScript(), 'utf8') + for (const result of await runInHosts(command, dir)) { + expect(result.stdout.trim(), result.label).toBe('{}') + expect(result.status, result.label).toBe(0) + } + }) + } + ) + + it.skipIf(!canRunLive)( + 'reports a non-blocking failure, never exit 2, when the entry is gone', + async () => { + await withTempDir(async (dir, scriptPath, command) => { + expect(existsSync(scriptPath)).toBe(false) + for (const result of await runInHosts(command, dir)) { + expect(result.stdout.trim(), result.label).toBe('') + expect(result.status, result.label).toBeGreaterThan(0) + expect(result.status, result.label).not.toBe(2) + } + }) + } + ) + + it.skipIf(!canRunLive)( + 'leaves no stray `nul` file behind in the working directory', + async () => { + // Why this is worth a test: adding `2>nul` to silence the missing-script line looks like + // tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the + // user's repo. Measured on Windows 11. Keep stderr unredirected. + await withTempDir(async (dir, _scriptPath, command) => { + await runInHosts(command, dir) + expect(readdirSync(dir)).not.toContain('nul') + }) + } + ) + } +) diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts index f7646f19578..e4fbc4d2ffc 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts @@ -1,30 +1,13 @@ import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' -// Why: a drive-letter path only. WINDOWS_CMD_SAFE_PATH also admits a UNC profile, and -// `//server/share/...` is not a command cmd.exe reliably starts. +// UNC paths cannot be started consistently by all Windows hook hosts. const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/ -/** - * Shortest launcher for a managed Windows `.cmd` hook: the script path itself (#18875). - * - * The encoded PowerShell launcher spent a full interpreter start-up per hook event to reach a - * script that exits at its first `ORCA_PANE_KEY` guard, and left a stdout-holding orphan behind - * when the hook's timeout kill landed. Measurements and the EDR trade are in - * `docs/reference/windows-edr-posture.md`. - * - * Returns null when the caller must keep the encoded launcher: a path either shell would mangle. - */ +/** A bare path works without guessing whether Claude selected Bash or PowerShell. */ export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null { if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) { return null } - // Why: forward slashes are the one separator both hosts read, and no token here is a switch - // MSYS can rewrite — a literal `cmd.exe /d /c ` does not survive Git Bash (measured). - const invocation = scriptPath.replaceAll('\\', '/') - // Why: neutral JSON when the script is missing (#14818), with no interpreter to Test-Path with. - // Valid in bash and cmd.exe; PowerShell 5.1 rejects `||`, which is what gates this on Git Bash. - // It also fires when cmd.exe itself exits non-zero (a failing AutoRun), printing `{}` twice — - // on that same box the encoded launcher exited 1 instead, so neither shape is clean there. - // Stderr stays unredirected: `2>nul` writes a literal `nul` file into the cwd under MSYS. - return `${invocation} || echo {}` + // The script owns failure handling; PowerShell 5.1 cannot parse a shell-level `||`. + return scriptPath.replaceAll('\\', '/') } diff --git a/src/main/agent-hooks/windows-hook-command-hosts.test.ts b/src/main/agent-hooks/windows-hook-command-hosts.test.ts new file mode 100644 index 00000000000..8707dd251a9 --- /dev/null +++ b/src/main/agent-hooks/windows-hook-command-hosts.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { getWindowsManagedLifecycleHook } from '../claude/hook-settings' + +const command = getWindowsManagedLifecycleHook( + 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' +).command +const input = '{"message":"café 日本語 & %PATH%", "hook_event_name":"Stop"}' + +// A POSIX fixture at the exact registered spelling exercises tokenization, not Windows batch execution. +describe.skipIf(process.platform === 'win32')('Windows command under available POSIX hosts', () => { + // PowerShell parsing is covered by the Windows-only host legs in windows-direct-cmd-hook-command.test.ts. + for (const shell of ['/bin/bash', '/bin/zsh']) { + it.skipIf(!existsSync(shell))( + `preserves invocation and reports a missing entry without exit 2: ${shell}`, + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-command-host-')) + const fixture = join(root, 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd') + const run = () => + runProcess({ + program: '/usr/bin/env', + args: ['-i', `HOME=${root}`, 'PATH=/usr/bin:/bin', shell, '-c', command], + cwd: root, + env: {}, + input, + timeoutMs: 5_000 + }) + try { + mkdirSync(dirname(fixture), { recursive: true }) + writeFileSync(fixture, '#!/bin/sh\nprintf "{}\\n"\ncat > payload.txt\nexit 0\n', { + mode: 0o755 + }) + const present = await run() + expect(present.code, present.stderr).toBe(0) + expect(present.stdout.trim()).toBe('{}') + expect(readFileSync(join(root, 'payload.txt'), 'utf8')).toBe(input) + rmSync(fixture) + const missing = await run() + expect(missing.timedOut).toBe(false) + expect(missing.code).toBeGreaterThan(0) + expect(missing.code).not.toBe(2) + expect(missing.stdout.trim()).toBe('') + expect(missing.stderr).not.toBe('') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + } +}) diff --git a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts index 09377f3adf5..59a409f82d9 100644 --- a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts +++ b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts @@ -15,6 +15,10 @@ const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) +vi.mock('../codex/codex-hook-trust-grant', () => ({ + grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' }) +})) + vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index 2cdb8c0f3fa..42487ec7efa 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -42,8 +42,7 @@ export function getWindowsPowerShellExecutablePath(): string { * * #18875 took that answer for the Claude lifecycle hook, which now registers the * managed `.cmd` path directly (`windows-direct-cmd-hook-command.ts`) and reaches - * this launcher only when the profile path is not cmd-safe or Git Bash is not - * resolvable. Every other caller still comes through here on every event. + * this launcher only when the profile path is not cmd-safe. Every other caller still comes through here on every event. */ export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index e02fe374701..475d79273b4 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -16,6 +16,12 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' +// Why: a backgrounded session runs in a daemon worker that inherited the dispatching +// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). +// Why exit, not the drain label: the drain parks in more.com and a worker is outside +// an Orca pane — the abandoned-stdin hang #11549 guards against. +export const WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD = 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0' + export function getManagedScript( target: 'local' | 'posix' = 'local', options: { @@ -36,11 +42,7 @@ export function getManagedScript( // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, // and outside an Orca pane the caller can abandon stdin, so more.com never returns. ...buildWindowsHookEnvironmentGuardLines(), - // Why: a backgrounded session runs in a daemon worker that inherited the dispatching - // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). - // Why exit, not the drain label: the drain parks in more.com and a worker is outside - // an Orca pane — the abandoned-stdin hang #11549 guards against. - 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', + WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD, ...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []), ...(options.skipWhenDevinImportsClaude ? [ diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index c9191d42dbd..0f7e0a0707f 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -15,8 +15,7 @@ vi.mock('electron', () => ({ } })) -// Why: the installed hook shape depends on whether Git Bash is resolvable on the host, so the -// install assertions below have to state which host they describe rather than inherit the box's. +// Mock discovery so registration can be checked with Git Bash both present and absent. const { gitBashAvailableMock } = vi.hoisted(() => ({ gitBashAvailableMock: { value: true } })) vi.mock('../git-bash', async (importOriginal) => ({ ...(await importOriginal()), @@ -55,10 +54,10 @@ describe('getWindowsManagedLifecycleHook', () => { // Why this is the whole point: the encoded launcher spent a PowerShell start-up per hook // event (471ms vs 201ms measured) before the .cmd could reach its ORCA_PANE_KEY guard, and // its orphan outlived the hook's timeout kill still holding the stdout the agent reads. - const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }) + const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH) expect(hook.args).toBeUndefined() - expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}') + expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd') expect(hook.command).not.toMatch(/powershell|-EncodedCommand|conhost/i) // Why: Git Bash/MSYS mangles backslash paths and rewrites slash-prefixed switches. expect(hook.command).not.toMatch(/\\/) @@ -66,7 +65,7 @@ describe('getWindowsManagedLifecycleHook', () => { }) it('falls back to the encoded launcher when the profile path is not cmd-safe', () => { - const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH, { gitBashAvailable: true }) + const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH) expect(hook.args).toBeUndefined() expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) @@ -79,18 +78,21 @@ describe('getWindowsManagedLifecycleHook', () => { expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd') }) - it('falls back to the encoded launcher when Git Bash is not resolvable', () => { - // Why: without Git Bash, Claude Code hosts the hook in PowerShell, and PowerShell 5.1 - // rejects `||` as a statement separator (measured) — every event would be a parse error. - const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false }) - - expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) + it('does not consult Git Bash availability', () => { + gitBashAvailableMock.value = false + try { + expect(getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH).command).toBe( + 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' + ) + } finally { + gitBashAvailableMock.value = true + } }) it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => { for (const hook of [ - getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }), - getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false }) + getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH), + getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH) ]) { expect(isClaudeManagedCommand(hook.command)).toBe(true) } @@ -242,7 +244,7 @@ describe('ClaudeHookService.install', () => { // Why: POSIX resolves the profile at runtime (`${HOME-}`, STA-3348). Windows cannot — // no single token expands in both Git Bash and cmd.exe — so it registers the absolute // path, as Codex/Grok/Devin/Antigravity already do (#18875). A moved profile is caught - // by getStatus's exact match and rewritten, and `|| echo {}` keeps a stale entry neutral. + // by getStatus's exact match and rewritten; a deleted entry reports an ordinary hook error. if (process.platform !== 'win32') { expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/')) } @@ -258,7 +260,12 @@ describe('ClaudeHookService.install', () => { true ) const managedScript = readFileSync( - join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), + join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'claude-hook-impl.cmd' : CLAUDE_SCRIPT_FILE_NAME + ), 'utf-8' ) expect(managedScript).toContain('DEVIN_PROJECT_DIR') @@ -506,7 +513,7 @@ describe('ClaudeHookService.install', () => { readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') ) as { hooks: Record } - const expected = `${scriptPath.replaceAll('\\', '/')} || echo {}` + const expected = scriptPath.replaceAll('\\', '/') for (const { eventName } of CLAUDE_EVENTS) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args, eventName).toBeUndefined() @@ -537,7 +544,9 @@ describe('ClaudeHookService.install', () => { try { const settingsPath = join(tmpHome, '.claude', 'settings.json') mkdirSync(join(tmpHome, '.claude'), { recursive: true }) - const stale = getWindowsManagedLifecycleHook(scriptPath, { gitBashAvailable: false }) + const stale = getWindowsManagedLifecycleHook( + 'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd' + ) writeFileSync( settingsPath, JSON.stringify({ @@ -605,9 +614,7 @@ describe('ClaudeHookService.install', () => { hooks: Record } expect(JSON.stringify(settings.hooks)).not.toContain('someone-else') - expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe( - `${scriptPath.replaceAll('\\', '/')} || echo {}` - ) + expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/')) expect(new ClaudeHookService().getStatus().state).toBe('installed') } finally { vi.unstubAllEnvs() @@ -625,7 +632,7 @@ describe('ClaudeHookService.install', () => { try { expect(new ClaudeHookService().install().state).toBe('installed') const script = readFileSync( - join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), + join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'), 'utf-8' ) expect(script).toContain('%SystemRoot%\\System32\\curl.exe') @@ -705,7 +712,10 @@ describe('backgrounded-session pane guard (#9236)', () => { vi.stubEnv('USERPROFILE', tmpHome) try { expect(new ClaudeHookService().install().state).toBe('installed') - const script = readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'claude-hook.cmd'), 'utf-8') + const script = readFileSync( + join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'), + 'utf-8' + ) const guard = script.split('\r\n').find((line) => line.includes('CLAUDE_JOB_DIR')) expect(guard).toBe('if not "%CLAUDE_JOB_DIR%"=="" exit /b 0') // Why: the drain parks in more.com, and a daemon worker is exactly the diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index bc27b56075e..0031d8413f9 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -16,6 +16,11 @@ import { } from '../agent-hooks/installer-utils-remote' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { getManagedScript } from './hook-script' +import { + getWindowsClaudeHookFileStatus, + installWindowsClaudeHookFiles, + refreshWindowsClaudeHookFiles +} from './windows-hook-files' export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' @@ -72,6 +77,18 @@ export class ClaudeHookService { this.options = options } + private get usesWindowsEntry(): boolean { + return process.platform === 'win32' && this.options.agent === 'claude' + } + + private managedScript(target: 'local' | 'posix' = 'local'): string { + return getManagedScript(target, { + source: this.options.source, + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) + } + // Why: Claude's settings loader rejects events newer than the running CLI, so its plan follows the // resolved version; OpenClaude and Qoder read their own settings files. private managedHookPlan(options: ClaudeHookInstallOptions): ClaudeManagedHookPlan { @@ -124,18 +141,16 @@ export class ClaudeHookService { state = 'partial' detail = `Managed hook missing for events: ${missing.join(', ')}` } - return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } + const status = { agent: this.options.agent, state, configPath, managedHooksPresent, detail } + return this.usesWindowsEntry ? getWindowsClaudeHookFileStatus(status, scriptPath) : status } async refreshManagedScripts(): Promise { - await refreshManagedScriptIfPresent( - getManagedScriptPath(this.options.settings), - getManagedScript('local', { - source: this.options.source, - skipWhenDevinImportsClaude: this.options.agent === 'claude', - skipWhenGrokImportsClaude: this.options.agent === 'claude' - }) - ) + const scriptPath = getManagedScriptPath(this.options.settings) + const payload = this.managedScript() + await (this.usesWindowsEntry + ? refreshWindowsClaudeHookFiles(scriptPath, payload) + : refreshManagedScriptIfPresent(scriptPath, payload)) // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. await refreshManagedScriptIfPresent( getStatusLineScriptPath(this.options.settings), @@ -165,14 +180,12 @@ export class ClaudeHookService { getManagedScriptFileName(this.options.settings), plan ) - writeManagedScript( - scriptPath, - getManagedScript('local', { - source: this.options.source, - skipWhenDevinImportsClaude: this.options.agent === 'claude', - skipWhenGrokImportsClaude: this.options.agent === 'claude' - }) - ) + const payload = this.managedScript() + if (this.usesWindowsEntry) { + installWindowsClaudeHookFiles(scriptPath, payload) + } else { + writeManagedScript(scriptPath, payload) + } if (plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) } else if (plan.statusLine === 'retire') { @@ -257,15 +270,7 @@ export class ClaudeHookService { // Why: write scripts before settings to avoid settings pointing to missing scripts. // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. - await writeManagedScriptRemote( - sftp, - remoteScriptPath, - getManagedScript('posix', { - source: this.options.source, - skipWhenDevinImportsClaude: this.options.agent === 'claude', - skipWhenGrokImportsClaude: this.options.agent === 'claude' - }) - ) + await writeManagedScriptRemote(sftp, remoteScriptPath, this.managedScript('posix')) // Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook // listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different // account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only). diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 381e4d26b6f..bb89cf38820 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -15,7 +15,6 @@ import { import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' -import { isGitBashAvailable } from '../git-bash' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { @@ -91,8 +90,7 @@ export function getManagedCommand( export function getManagedLifecycleHook( scriptPath: string, - settings = CLAUDE_HOOK_SETTINGS, - options: WindowsManagedLifecycleHookOptions = {} + settings = CLAUDE_HOOK_SETTINGS ): HookCommandConfig { if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) { return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) @@ -105,22 +103,12 @@ export function getManagedLifecycleHook( timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } - return getWindowsManagedLifecycleHook(scriptPath, options) + return getWindowsManagedLifecycleHook(scriptPath) } -export type WindowsManagedLifecycleHookOptions = { gitBashAvailable?: boolean } - -// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained. -export function getWindowsManagedLifecycleHook( - scriptPath: string, - options: WindowsManagedLifecycleHookOptions = {} -): HookCommandConfig { - // Why (#18875): the encoded launcher cost a PowerShell start-up per hook event. Take the direct - // path only where the host can parse `||` — Git Bash can, Windows PowerShell 5.1 cannot. - const directCommand = - (options.gitBashAvailable ?? isGitBashAvailable()) - ? wrapWindowsDirectCmdHookCommand(scriptPath) - : null +// Some compatible consumers ignore args, so keep the invocation self-contained. +export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { + const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath) if (directCommand) { return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } diff --git a/src/main/claude/windows-hook-files.test.ts b/src/main/claude/windows-hook-files.test.ts new file mode 100644 index 00000000000..a3d369e75ec --- /dev/null +++ b/src/main/claude/windows-hook-files.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as osModule from 'node:os' +import { join } from 'node:path' +import * as refresh from '../agent-hooks/managed-hook-script-refresh' +import { readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils' +import { ClaudeHookService } from './hook-service' +import { getWindowsManagedLifecycleHook } from './hook-settings' +import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files' +import { codebuddyHookService } from '../codebuddy/hook-service' +import { qoderHookService } from '../qoder/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' + +const { home } = vi.hoisted(() => ({ home: { path: '' } })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => home.path +})) +vi.mock('electron', () => ({ app: { getPath: () => home.path } })) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +let entry: string +let payload: string +let settings: string +const service = new ClaudeHookService() + +beforeEach(() => { + home.path = mkdtempSync(join(tmpdir(), 'claude-windows-files-')) + entry = join(home.path, '.orca', 'agent-hooks', 'claude-hook.cmd') + payload = getWindowsClaudeHookPayloadPath(entry) + settings = join(home.path, '.claude', 'settings.json') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() + rmSync(home.path, { recursive: true, force: true }) +}) + +describe('Windows Claude hook files', () => { + it('installs a delegating entry and a payload that answers before bounded delivery', () => { + expect(service.install().state).toBe('installed') + expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry()) + const body = readFileSync(payload, 'utf8') + expect(body.indexOf('echo {}')).toBeLessThan(body.indexOf('curl.exe')) + expect(body).toContain('--connect-timeout 0.5 --max-time 1.5') + expect(body).toContain('--data-urlencode "payload@-" >nul 2>&1\r\nexit /b 0') + expect(body).toContain('DEVIN_PROJECT_DIR') + }) + + it('repairs a missing payload from the surviving entry without touching settings', async () => { + service.install() + rmSync(payload) + expect(service.getStatus().state).toBe('partial') + const configBefore = readFileSync(settings, 'utf8') + await service.refreshManagedScripts() + expect(service.getStatus().state).toBe('installed') + expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry()) + expect(readFileSync(payload, 'utf8')).toContain('/hook/claude') + expect(readFileSync(settings, 'utf8')).toBe(configBefore) + }) + + it.each(['entry', 'both'])('leaves a missing %s to install()', async (missing) => { + service.install() + rmSync(entry) + if (missing === 'both') { + rmSync(payload) + } + await service.refreshManagedScripts() + expect(existsSync(entry)).toBe(false) + expect(service.getStatus().state).toBe('partial') + expect(service.install().state).toBe('installed') + expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry()) + expect(readFileSync(payload, 'utf8')).toContain('/hook/claude') + }) + + it('leaves the old single-file entry intact when payload publication fails', async () => { + mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true }) + const oldEntry = '@echo off\r\necho {}\r\nexit /b 0\r\n' + writeFileSync(entry, oldEntry) + vi.spyOn(refresh, 'restoreManagedScript').mockRejectedValueOnce(new Error('disk full')) + await expect(service.refreshManagedScripts()).rejects.toThrow('disk full') + expect(readFileSync(entry, 'utf8')).toBe(oldEntry) + expect(existsSync(payload)).toBe(false) + }) + + it('publishes the payload before replacing a legacy entry', async () => { + mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true }) + writeFileSync(entry, 'legacy payload') + const restore = refresh.restoreManagedScript + const writes: string[] = [] + vi.spyOn(refresh, 'restoreManagedScript').mockImplementation(async (path, content) => { + if (path === entry) { + expect(readFileSync(payload, 'utf8')).toContain('/hook/claude') + } + writes.push(path) + await restore(path, content) + }) + await service.refreshManagedScripts() + expect(writes).toEqual([payload, entry]) + expect(existsSync(settings)).toBe(false) + }) + + it('keeps inert scripts on uninstall and never creates an entry from an orphan payload', async () => { + service.install() + service.remove() + expect(JSON.stringify(readHooksJson(settings))).not.toContain('claude-hook.cmd') + // Like every other agent's script: a session still holding the old settings keeps answering. + expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry()) + rmSync(entry) + writeFileSync(payload, 'orphan payload') + await service.refreshManagedScripts() + expect(existsSync(entry)).toBe(false) + expect(readFileSync(payload, 'utf8')).toBe('orphan payload') + }) + + it('migrates old command forms while preserving user hooks', () => { + const encoded = getWindowsManagedLifecycleHook( + 'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd' + ) + writeHooksJson(settings, { + hooks: { + Stop: [{ hooks: [encoded, { type: 'command', command: 'echo user-hook' }] }], + PreToolUse: [ + { + hooks: [ + { type: 'command', command: 'C:/old/.orca/agent-hooks/claude-hook.cmd || echo {}' } + ] + } + ] + } + }) + service.install() + const config = JSON.stringify(readHooksJson(settings)) + expect(config).not.toContain('|| echo {}') + expect(config).not.toContain('C:/old/') + expect(config).toContain('echo user-hook') + expect(service.getStatus().state).toBe('installed') + }) + + it('keeps compatible agents on their existing single-file scripts', async () => { + for (const [name, compatible] of [ + ['qoder', qoderHookService], + ['codebuddy', codebuddyHookService], + ['openclaude', openClaudeHookService] + ] as const) { + expect(compatible.install().state).toBe('installed') + await compatible.refreshManagedScripts() + const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`) + expect(readFileSync(path, 'utf8')).toContain( + `/hook/${name === 'openclaude' ? 'claude' : name}` + ) + expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd') + expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe( + false + ) + } + }) +}) diff --git a/src/main/claude/windows-hook-files.ts b/src/main/claude/windows-hook-files.ts new file mode 100644 index 00000000000..13ef168f520 --- /dev/null +++ b/src/main/claude/windows-hook-files.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { writeManagedScript } from '../agent-hooks/installer-utils' +import { restoreManagedScript, scriptStillExists } from '../agent-hooks/managed-hook-script-refresh' +import { + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD } from './hook-script' + +const PAYLOAD_FILE_NAME = 'claude-hook-impl.cmd' + +export function getWindowsClaudeHookPayloadPath(entryPath: string): string { + return join(dirname(entryPath), PAYLOAD_FILE_NAME) +} + +export function getWindowsClaudeHookFileStatus( + status: AgentHookInstallStatus, + entryPath: string +): AgentHookInstallStatus { + if ( + status.state === 'installed' && + (!existsSync(entryPath) || !existsSync(getWindowsClaudeHookPayloadPath(entryPath))) + ) { + return { ...status, state: 'partial', detail: 'Managed Claude hook script is missing' } + } + return status +} + +export function installWindowsClaudeHookFiles(entryPath: string, payload: string): void { + writeManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload) + writeManagedScript(entryPath, getWindowsClaudeHookEntry()) +} + +export function getWindowsClaudeHookEntry(): string { + return [ + '@echo off', + // Inherited delayed expansion would eat exclamation marks in the profile path. + 'setlocal DisableDelayedExpansion', + `set "ORCA_CLAUDE_HOOK_IMPL=%~dp0${PAYLOAD_FILE_NAME}"`, + 'if not exist "%ORCA_CLAUDE_HOOK_IMPL%" goto :missing_impl', + // Transfer control without CALL's second expansion of percent signs in the path. + // A payload that exists but cannot start (locked, quarantined) falls through to the neutral reply. + '"%ORCA_CLAUDE_HOOK_IMPL%"', + ':missing_impl', + 'echo {}', + ...buildWindowsHookEnvironmentGuardLines(), + WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD, + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') +} + +export async function refreshWindowsClaudeHookFiles( + entryPath: string, + payload: string +): Promise { + // A surviving entry is Orca-owned; creating a missing one stays install()'s presence-gated job. + if (!(await scriptStillExists(entryPath))) { + return + } + // Publish the payload first so a failed migration leaves the previous single-file hook intact. + await restoreManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload) + await restoreManagedScript(entryPath, getWindowsClaudeHookEntry()) +} diff --git a/src/main/codex-accounts/codex-account-registration.ts b/src/main/codex-accounts/codex-account-registration.ts index fbdf0db1a68..6abab873563 100644 --- a/src/main/codex-accounts/codex-account-registration.ts +++ b/src/main/codex-accounts/codex-account-registration.ts @@ -219,7 +219,7 @@ export class CodexAccountRegistration { target: CodexAccountSelectionTarget | undefined ): void { // Why: quota probes against a cold per-account CODEX_HOME can take 10–25s - // (RPC + PTY fallback) and queue behind an in-flight global usage refresh. + // (RPC + HTTP fallback) and queue behind an in-flight global usage refresh. // The refresh synchronously flips usage to "fetching" before its first await, // so the switcher updates immediately; the probe itself must never block or // fail the already-durable account mutation. diff --git a/src/main/codex-accounts/service-quota-refresh-decoupling.test.ts b/src/main/codex-accounts/service-quota-refresh-decoupling.test.ts index b8e74b1cce5..925c5be81ed 100644 --- a/src/main/codex-accounts/service-quota-refresh-decoupling.test.ts +++ b/src/main/codex-accounts/service-quota-refresh-decoupling.test.ts @@ -32,7 +32,7 @@ describe('CodexAccountService config sync', () => { registerCodexAccountsTestHomes() // Why: quota probes against a cold per-account CODEX_HOME can take 10–25s - // (RPC + PTY fallback) and queue behind an in-flight global usage refresh; + // (RPC + HTTP fallback) and queue behind an in-flight global usage refresh; // account mutations must never block on — or fail because of — that probe. describe('quota refresh decoupling', () => { function createAccountOneSettings(): GlobalSettings { diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 326bba56fb5..62fe4e68e11 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -565,6 +565,16 @@ describe('HeadlessEmulator', () => { expect(snapshot.modes.sgrMouseMode).toBe(false) expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h') + // Replay readers must not guess the encoding of an active tracking mode. + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006l') + }) + + it('states no mouse encoding while nothing tracks the mouse', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?1002h\x1b[?1002l') + + expect(emulator.getSnapshot().rehydrateSequences).not.toContain('\x1b[?1006') }) it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => { diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 27863c82ec9..e404ccb04e2 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -66,7 +66,9 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { // background that the round trip does not restore to default. Verified as upstream, not a // regression, by replaying it against the previous build // (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold. - 'dsh-tui-ready-no-key': 10 + 'dsh-tui-ready-no-key': 10, + // Hermes banner cells restore with an extra bold bit under the jitter schedule. + 'hermes-tui-ready': 2 } // Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. @@ -204,6 +206,12 @@ describe('serialize round trip over captured PTY transcripts', () => { console.log(`${transcript.name} ${JSON.stringify(counts)}`) } expect(blocking).toEqual([]) + if (SEEDS === 2 && transcript.name === 'hermes-tui-ready') { + expect(failureSignatures).toEqual([ + 'jitter/false/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63', + 'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63' + ]) + } if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) { expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? []) } else if (!OLD_ADDON_PATH && SEEDS === 2) { diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index 8537de08961..b00145bacc8 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -19,7 +19,8 @@ export function buildRehydrateSequences(modes: TerminalModes): string { } // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. - switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + const trackingMode = modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none' + switch (trackingMode) { case 'x10': seqs.push('\x1b[?9h') break @@ -41,6 +42,9 @@ export function buildRehydrateSequences(modes: TerminalModes): string { seqs.push('\x1b[?1016h') } else if (modes.sgrMouseMode) { seqs.push('\x1b[?1006h') + } else if (trackingMode !== 'none') { + // Why: states the default encoding, so a replay reader never has to guess it. + seqs.push('\x1b[?1006l') } return seqs.join('') } diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts new file mode 100644 index 00000000000..3711bdf038c --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers' + +const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' +const wrapped = `\n\n\n${prompt}\n\n` +function decode(text: string, role = 'user', array = false) { + return decodeClaudeTranscriptLine( + JSON.stringify({ + type: role, + uuid: 'user', + message: { content: array ? [{ type: 'text', text }] : text } + }), + 'fallback' + )! +} + +describe('Claude whole-block paste envelope', () => { + it.each([false, true])('decodes host user content (array=%s)', (array) => { + expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }]) + }) + it('accepts CRLF and a wrapper without ids', () => { + expect(decode(`\r\n${prompt}\r\n`).blocks).toEqual([ + { type: 'text', text: prompt } + ]) + }) + it('decodes a paste whose own text quotes differently identified or bare tags', () => { + const quoted = `\nx\n\n` + expect( + decode(`\n${quoted}\n`).blocks + ).toEqual([{ type: 'text', text: quoted }]) + }) + it.each([ + `Explain this:\n${wrapped}`, + `\n\nx\n`, + `\n\nx\n\n`, + wrapped.replace('id="7e64">\n', 'id="other">\n'), + wrapped.replace('', ''), + wrapped.replace('', ''), + `${wrapped}\n${wrapped}` + ])('preserves prose and nonmatching envelopes', (text) => { + expect(decode(text).blocks).toEqual([{ type: 'text', text }]) + }) + it('leaves assistant text untouched', () => { + expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }]) + }) + it('handles a large prompt in linear passes', () => { + const text = 'line\n'.repeat(50_000) + expect( + normalizeNativeChatUserText(`\n${text}\n`) + ).toBe(text.trim().replace(/\s+/g, ' ')) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 5202035bbc6..bc7a988e79e 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -15,6 +15,7 @@ import { } from '../ai-vault/session-scanner-values' import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers' import { claudeContentBlocks } from './transcript-record-blocks' +import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content' import { claudeInterruptedMessageId } from './transcript-turn-markers' const MAX_EDIT_PATCH_HUNKS = 40 @@ -124,7 +125,7 @@ export function decodeClaudeTranscriptLine( return { id: messageId ?? fallbackId, role: claudeMessageRole(role, blocks), - blocks, + blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, timestamp, source: 'transcript' } diff --git a/src/main/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts index 954a545c2b9..fbb7d4058c0 100644 --- a/src/main/qoder/hook-service.test.ts +++ b/src/main/qoder/hook-service.test.ts @@ -80,16 +80,12 @@ describe('Qoder Windows hook shell', () => { it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { - const hook = getManagedLifecycleHook( - 'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', - { - configDirName: '.qoder', - scriptBaseName: 'qoder-hook', - usesWindowsCompatLauncher: true, - windowsHookShell: 'powershell' - }, - { gitBashAvailable: true } - ) + const hook = getManagedLifecycleHook('C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }) expect(hook.shell).toBe('powershell') expect(hook.command).toContain('$env:USERPROFILE') expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/) diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts index f482d2594b3..5ce9df8675e 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -26,10 +26,6 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' -function makeDisposable() { - return { dispose: vi.fn() } -} - function makeRpcChild() { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter @@ -60,9 +56,14 @@ describe('fetchCodexRateLimits auth errors', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') + vi.stubGlobal('fetch', vi.fn()) }) - it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns Codex RPC auth refresh errors without masking them behind a fallback', async () => { const rpcChild = makeRpcChild() const authError = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' @@ -105,10 +106,11 @@ describe('fetchCodexRateLimits auth errors', () => { status: 'error', error: authError }) + expect(fetch).not.toHaveBeenCalled() expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('returns the app-server chatgpt-auth-required error without spawning the PTY probe', async () => { + it('returns the app-server chatgpt-auth-required error without falling back', async () => { const rpcChild = makeRpcChild() const authError = 'chatgpt authentication required to read rate limits' @@ -150,75 +152,7 @@ describe('fetchCodexRateLimits auth errors', () => { status: 'error', error: authError }) + expect(fetch).not.toHaveBeenCalled() expect(ptySpawnMock).not.toHaveBeenCalled() }) - - it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => { - const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {} - const authError = - 'Error loading configuration: Your authentication session could not be refreshed automatically.' - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn((callback) => { - ptyHandlers.onExit = callback - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - ptyHandlers.onData?.(`${authError}\n`) - ptyHandlers.onExit?.() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'error', - error: authError - }) - }) - - it('stops a PTY probe when Codex renders its sign-in screen', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - const ptyWrite = vi.fn() - const ptyKill = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: ptyWrite, - kill: ptyKill - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n') - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'error', - error: 'Sign in with ChatGPT' - }) - expect(ptyWrite).not.toHaveBeenCalled() - expect(ptyKill).toHaveBeenCalledOnce() - }) }) diff --git a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts b/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts index db0c4a7f5ed..d5b2d56b681 100644 --- a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts +++ b/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts @@ -129,7 +129,7 @@ describe('fetchCodexRateLimits probe shutdown', () => { } }) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(19_999) expect(rpcChild.kill).not.toHaveBeenCalled() @@ -169,7 +169,7 @@ describe('fetchCodexRateLimits probe shutdown', () => { }) try { - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(9_999) expect(rpcChild.kill).not.toHaveBeenCalled() @@ -200,12 +200,10 @@ describe('fetchCodexRateLimits probe shutdown', () => { }) const first = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-a' }) await vi.advanceTimersByTimeAsync(0) const second = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-a' }) await vi.advanceTimersByTimeAsync(0) @@ -241,13 +239,11 @@ describe('fetchCodexRateLimits probe shutdown', () => { try { const first = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-error' }) await vi.advanceTimersByTimeAsync(0) firstChild.emit('error', new Error('stdio failed')) const second = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-error' }) await vi.advanceTimersByTimeAsync(0) @@ -308,14 +304,12 @@ describe('fetchCodexRateLimits probe shutdown', () => { try { const first = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: home }) await vi.advanceTimersByTimeAsync(0) expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM') const second = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: home }) await vi.advanceTimersByTimeAsync(4_999) @@ -357,13 +351,11 @@ describe('fetchCodexRateLimits probe shutdown', () => { try { const first = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-async-stdin-error' }) await vi.advanceTimersByTimeAsync(0) firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) const second = fetchCodexRateLimits({ - allowPtyFallback: false, codexHomePath: '/managed/home-async-stdin-error' }) diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index b698c706c6a..0fd1f5f2c1e 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -130,8 +130,7 @@ describe('Codex rate-limit process contract', () => { it('starts a read-only non-interactive app-server with the managed home', async () => { await expect( fetchCodexRateLimits({ - codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME, - allowPtyFallback: false + codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME }) ).resolves.toMatchObject({ provider: 'codex', diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts deleted file mode 100644 index 15c338aad89..00000000000 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ - childSpawnMock: vi.fn(), - resolveCodexCommandMock: vi.fn(), - ptySpawnMock: vi.fn() -})) - -vi.mock('node:child_process', () => ({ - spawn: childSpawnMock -})) - -vi.mock('../codex-cli/command', () => ({ - resolveCodexCommand: resolveCodexCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock -})) - -// Auth gate is covered separately; these tests assume a signed-in Codex. -vi.mock('./codex-auth-presence', () => ({ - probeCodexAuthPresence: vi.fn(() => 'present') -})) - -import { fetchCodexRateLimits } from './codex-fetcher' - -function makeDisposable() { - return { dispose: vi.fn() } -} - -describe('fetchCodexRateLimits PTY settle timers', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveCodexCommandMock.mockReturnValue('codex') - }) - - it('coalesces the PTY fallback status settle timer while output keeps streaming', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - // Pending: PTY timeout + the delayed /status Enter keypress. - expect(vi.getTimerCount()).toBe(2) - - onPtyData('5h limit: 17%\n') - onPtyData('Weekly limit: 23%\n') - onPtyData('still rendering\n') - // One settle timer armed — not one per data chunk. - expect(vi.getTimerCount()).toBe(3) - - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: { usedPercent: 17 }, - weekly: { usedPercent: 23 }, - status: 'ok' - }) - }) - - it('keeps the reset text on the weekly window for weekly-only plans', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') - - await vi.advanceTimersByTimeAsync(500) - - const fiveDays23h = (5 * 24 + 23) * 60 * 60 * 1000 - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { - usedPercent: 76, - resetDescription: '5d 23h', - resetsAt: Date.now() + fiveDays23h - }, - status: 'ok' - }) - }) - - it('keeps each window reset text on its own window for dual-window plans', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData('5h limit: 17% (resets in 2h 30m)\nWeekly limit: 23% (resets in 5d 3h)\n') - - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: { - usedPercent: 17, - resetDescription: '2h 30m', - resetsAt: Date.now() + (2 * 60 + 30) * 60 * 1000 - }, - weekly: { - usedPercent: 23, - resetDescription: '5d 3h', - resetsAt: Date.now() + (5 * 24 + 3) * 60 * 60 * 1000 - }, - status: 'ok' - }) - }) - - it('parses the framed codex 0.145 status panel via the /status nudge', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - const write = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write, - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - // codex ≥0.145 shows a '›' composer with placeholder text, never a bare '>' prompt. - onPtyData('›Summarize recent commits') - expect(write).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(2500) - expect(write).toHaveBeenCalledWith('/status') - await vi.advanceTimersByTimeAsync(350) - expect(write).toHaveBeenCalledWith('\r') - - onPtyData( - '│ Weekly limit: \x1b[?2026h\x1b[0 q[█████████░░░░░░░░░░░] 43% left\x1b[?2026l (resets 10:21 on 28 Jul) │\n' + - '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' - ) - await vi.advanceTimersByTimeAsync(500) - - const expectedReset = new Date(new Date().getFullYear(), 6, 28, 10, 21) - if (expectedReset.getTime() <= Date.now()) { - expectedReset.setFullYear(expectedReset.getFullYear() + 1) - } - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { - usedPercent: 57, - resetDescription: '10:21 on 28 Jul', - resetsAt: expectedReset.getTime() - }, - status: 'ok' - }) - }) - - it('never selects a model-scoped weekly row even when it renders first', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData( - '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' + - '│ Weekly limit: [█████████░░░░░░░░░░░] 43% left (resets 10:21 on 28 Jul) │\n' - ) - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 57, resetDescription: '10:21 on 28 Jul' }, - status: 'ok' - }) - }) - - it('re-sends Enter once when the panel does not render after the first submit', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - const write = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write, - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - await vi.advanceTimersByTimeAsync(350) - expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(3000) - expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(2) - - onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 76 }, - status: 'ok' - }) - }) -}) diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts index 441be8d836d..3231d71876d 100644 --- a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -62,7 +62,8 @@ let stubPath: string function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> { process.env.ORCA_STUB_CODEX_STDERR = stderr process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode) - return fetchCodexRateLimits({ allowPtyFallback: false }) + // Why: a temp home with no auth.json keeps the HTTP fallback off the developer's real login. + return fetchCodexRateLimits({ codexHomePath: tempRoot }) } describe('Codex RPC exit diagnostics', () => { diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts index d825bb65fd8..ee23ead1966 100644 --- a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -82,7 +82,7 @@ describe('codex rate-limit spawn runtime pairing', () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv @@ -105,7 +105,7 @@ describe('codex rate-limit spawn runtime pairing', () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string @@ -127,7 +127,7 @@ describe('codex rate-limit spawn runtime pairing', () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 7d5b7423f7c..ec22103901b 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -58,13 +58,8 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' import { probeCodexAuthPresence } from './codex-auth-presence' -import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' -function makeDisposable() { - return { dispose: vi.fn() } -} - function makeRpcChild() { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter @@ -116,23 +111,24 @@ function respondToRpcRateLimitRead( }) } -function makePtyTerm() { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((callback: (data: string) => void) => { - dataHandler = callback - return makeDisposable() - }), - onExit: vi.fn((callback: () => void) => { - exitHandler = callback - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } +function mockBackendUsage(): void { + readFileMock.mockResolvedValue( + JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) + ) + // A Response body reads once; usage and reset credits each fetch. + vi.mocked(fetch).mockImplementation( + async () => + new Response( + JSON.stringify({ + plan_type: 'plus', + rate_limit: { + primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 }, + secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 } + }, + rate_limit_reset_credits: { available_count: 0, credits: [] } + }) + ) + ) } describe('fetchCodexRateLimits', () => { @@ -168,9 +164,7 @@ describe('fetchCodexRateLimits', () => { it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => { isBackfillPendingMock.mockReturnValue(true) - await expect( - fetchCodexRateLimits({ codexHomePath: '/managed-home', allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({ status: 'error', error: expect.stringContaining('session index') }) @@ -219,38 +213,11 @@ describe('fetchCodexRateLimits', () => { } ) - it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { - const onDataDisposable = makeDisposable() - const onExitDisposable = makeDisposable() - const killMock = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), - write: vi.fn(), - kill: killMock - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(15_000) - await resultPromise - - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - }) - it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string @@ -262,26 +229,7 @@ describe('fetchCodexRateLimits', () => { await resultPromise }) - it('spawns the PTY fallback in a bounded non-root cwd', async () => { - const term = makePtyTerm() - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue(term) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) - - term.emitExit() - await resultPromise - }) - - it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => { + it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) const controller = new AbortController() @@ -297,77 +245,40 @@ describe('fetchCodexRateLimits', () => { error: 'Rate-limit fetch aborted' }) expect(rpcChild.kill).toHaveBeenCalledTimes(1) + expect(fetch).not.toHaveBeenCalled() expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => { - const term = makePtyTerm() - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue(term) - const controller = new AbortController() - const killMock = term.kill - - const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(killMock).toHaveBeenCalledTimes(1) - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('falls back to the PTY status reader when RPC exits before returning usage', async () => { + it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => { const rpcChild = makeRpcChild() - const ptyHandlers: { onData?: (data: string) => void } = {} - childSpawnMock.mockReturnValue(rpcChild) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) + mockBackendUsage() const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') - await vi.advanceTimersByTimeAsync(0) - - expect(ptySpawnMock).toHaveBeenCalled() - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - onPtyData('>') - onPtyData('5h limit: 7%\nWeekly limit: 12%\n') - await vi.advanceTimersByTimeAsync(500) await expect(resultPromise).resolves.toMatchObject({ provider: 'codex', - session: { usedPercent: 7 }, - weekly: { usedPercent: 12 }, + session: { usedPercent: 7, windowMinutes: 300 }, + weekly: { usedPercent: 12, windowMinutes: 10080 }, status: 'ok', error: null }) + expect(fetch).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/wham/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) + }) + ) + expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('does not start the PTY fallback when disabled for background account previews', async () => { + it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -379,6 +290,28 @@ describe('fetchCodexRateLimits', () => { status: 'error' }) expect(rpcChild.stdin.listenerCount('error')).toBe(0) + expect(fetch).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + + it('shows the RPC exit reason when the HTTP fallback is rejected', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + mockBackendUsage() + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 })) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + rpcChild.emit('close', 1, null) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: expect.stringContaining('exit code 1') + }) + expect(fetch).toHaveBeenCalledTimes(1) expect(ptySpawnMock).not.toHaveBeenCalled() }) @@ -386,7 +319,7 @@ describe('fetchCodexRateLimits', () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + const resultPromise = fetchCodexRateLimits() // Why: without an initialize response only the 30s boot deadline fires. await vi.advanceTimersByTimeAsync(30_000) @@ -810,27 +743,18 @@ describe('fetchCodexRateLimits', () => { } }) - it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { + it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => { const originalPlatform = process.platform - const originalCodexHome = process.env.CODEX_HOME Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex' - const rpcChild = makeRpcChild() - const ptyHandlers: { onData?: (data: string) => void } = {} childSpawnMock.mockReturnValue(rpcChild) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) + readFileMock.mockResolvedValue( + JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) + ) + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 })) try { const resultPromise = fetchCodexRateLimits({ @@ -838,53 +762,12 @@ describe('fetchCodexRateLimits', () => { }) await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') - await vi.advanceTimersByTimeAsync(0) - const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] - expect(spawnFile).toBe('wsl.exe') - expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c']) - const shellCommand = spawnArgs.at(-1) as string - expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd') - expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc') - expect(shellCommand).not.toContain('exec 3<&0') - expect(shellCommand).not.toContain('exec /dev/null') - expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') - expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(shellCommand).toContain( - "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" - ) - expect(shellCommand).toContain('exec codex ') - expect(shellCommand).toContain('features.plugins=false') - expect(shellCommand).not.toContain('_orca_codex') - expect(shellCommand).not.toContain('wsl-codex-path') - expect(spawnOptions).toEqual( - expect.objectContaining({ - cwd: expect.stringContaining('rate-limit-pty-cwd'), - env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) - }) - ) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - onPtyData('>') - onPtyData('5h limit: 17%\nWeekly limit: 23%\n') - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: { usedPercent: 17 }, - weekly: { usedPercent: 23 }, - status: 'ok' - }) + await expect(resultPromise).resolves.toMatchObject({ status: 'error' }) + expect(fetch).toHaveBeenCalledTimes(1) + expect(childSpawnMock).toHaveBeenCalledTimes(1) + expect(ptySpawnMock).not.toHaveBeenCalled() } finally { - if (originalCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = originalCodexHome - } Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 804cd22c660..e10202fd430 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -2,10 +2,7 @@ import type { CodexRateLimitResetOutcome, ProviderRateLimits } from '../../share import { isCodexAuthError } from '../../shared/codex-auth-errors' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' -import { - CODEX_DISABLE_PLUGINS_ARGS, - CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS -} from '../codex-cli/codex-read-only-app-server-args' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { resolveCodexCommand } from '../codex-cli/command' // Why: import from the shared module, not the codex-cli re-export, so a test that // mocks '../codex-cli/command' does not have to restate this pure helper. @@ -16,9 +13,7 @@ import { } from '../codex-cli/codex-home-process-lock' import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { spawnProcess } from '../../shared/child-process/run-process' -import { getCmdExePath } from '../win32-utils' import { probeCodexAuthPresence } from './codex-auth-presence' import { fetchCodexRateLimitsViaBackend, @@ -26,7 +21,6 @@ import { } from './codex-backend-usage-client' import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' -import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe' import { terminateCodexProbeChild } from './codex-probe-termination' import { consumeCodexRateLimitResetCreditFromBackend, @@ -47,17 +41,11 @@ const WSL_RPC_TIMEOUT_MS = 25_000 const RPC_INIT_TIMEOUT_MS = 30_000 const WSL_RPC_INIT_TIMEOUT_MS = 40_000 -// Keep the PTY fallback aligned with the RPC probe: rate-limit collection does -// not need marketplace/plugin startup, and those background clones can outlive -// the short-lived probe process. -const CODEX_RATE_LIMIT_PLUGIN_ARGS = CODEX_DISABLE_PLUGINS_ARGS - export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions function buildWslCodexCommand( codexHomePath: string, - args: string[], - isolateRpcStdio: boolean + args: string[] ): { command: string; args: string[] } | null { const wslInfo = parseWslUncPath(codexHomePath) if (process.platform !== 'win32' || !wslInfo) { @@ -67,15 +55,17 @@ function buildWslCodexCommand( ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}` ].join(' && ') - const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${ - isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' - }` + const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')} <&3 >&4 3<&- 4>&-` const loginShellCommand = buildWslLoginShellCommand( [setupCommands, `exec codex ${execSuffix}`].join(' && ') ) - const command = isolateRpcStdio - ? ['exec 3<&0', 'exec 4>&1', 'exec /dev/null', loginShellCommand].join('\n') - : loginShellCommand + const command = [ + 'exec 3<&0', + 'exec 4>&1', + 'exec /dev/null', + loginShellCommand + ].join('\n') return { command: 'wsl.exe', args: buildWslExecArgs(wslInfo.distro, ['sh', '-c', command]) @@ -106,7 +96,7 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise { try { const result = await fetchCodexRateLimitsViaBackend(fetchCodexUsage, options) - if (options.signal?.aborted) { + if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } return result ? supplementCodexRateLimitResetCredits(result, fetchCodexResetCredits, options) : null } catch { - return options.signal?.aborted ? abortedCodexRateLimitResult() : null + return options?.signal?.aborted ? abortedCodexRateLimitResult() : null } } @@ -218,10 +186,11 @@ export async function fetchCodexRateLimits( ) } - if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) { - const backendResult = await fetchWslBackend(options) + const isWslHome = Boolean(options?.codexHomePath && parseWslUncPath(options.codexHomePath)) + if (isWslHome) { + const backendResult = await fetchBackendUsage(options) if (backendResult) { - return options.signal?.aborted ? abortedCodexRateLimitResult() : backendResult + return options?.signal?.aborted ? abortedCodexRateLimitResult() : backendResult } } @@ -233,9 +202,12 @@ export async function fetchCodexRateLimits( ) } - const homeLockKey = resolveCodexHomeProcessLockKey(options?.codexHomePath) + let rpcFailure: ProviderRateLimits try { - const rpcResult = await withCodexHomeProcessLock(homeLockKey, () => fetchViaRpc(options)) + const rpcResult = await withCodexHomeProcessLock( + resolveCodexHomeProcessLockKey(options?.codexHomePath), + () => fetchViaRpc(options) + ) if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } @@ -243,39 +215,24 @@ export async function fetchCodexRateLimits( const supplemented = await supplementBackendMetadata(rpcResult, options) return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented } - if (isCodexAuthError(rpcResult.error) || options?.allowPtyFallback === false) { + if (isCodexAuthError(rpcResult.error)) { return rpcResult } + rpcFailure = rpcResult } catch { if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } - if (options?.allowPtyFallback === false) { - return codexUnavailable('RPC failed', 'error') - } + rpcFailure = codexUnavailable('RPC failed', 'error') } - try { - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const ptyResult = await withCodexHomeProcessLock(homeLockKey, () => - fetchCodexRateLimitsViaPty(() => resolvePtyCommand(options), options) - ) - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const supplemented = await supplementBackendMetadata(ptyResult, options) - return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented - } catch (error) { - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const message = error instanceof Error ? error.message : 'Unknown error' - const isNotInstalled = message.includes('ENOENT') - return codexUnavailable( - isNotInstalled ? 'Codex CLI not found' : withMacTailscaleDnsHint(message), - isNotInstalled ? 'unavailable' : 'error' - ) + if (isWslHome) { + return rpcFailure } + // Why: read usage over HTTP, never by driving the interactive Codex TUI — keystrokes sent there can accept startup dialogs such as "Update now" (#17415). + const backendResult = await fetchBackendUsage(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + return backendResult ?? rpcFailure } diff --git a/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts b/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts deleted file mode 100644 index de3c6631dcc..00000000000 --- a/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe' - -describe('Codex PTY rate-limit probe cancellation', () => { - it('does not resolve the process command after cancellation', async () => { - const controller = new AbortController() - const resolveCommand = vi.fn(() => ({ - command: 'codex', - args: [], - cwd: '.', - env: {} - })) - controller.abort() - - await expect( - fetchCodexRateLimitsViaPty(resolveCommand, { signal: controller.signal }) - ).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(resolveCommand).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/codex-pty-rate-limit-probe.ts b/src/main/rate-limits/codex-pty-rate-limit-probe.ts deleted file mode 100644 index 194e7d19fd6..00000000000 --- a/src/main/rate-limits/codex-pty-rate-limit-probe.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { extractCodexAuthError } from '../../shared/codex-auth-errors' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' -import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' -import { - hasCodexPtyRateLimit, - parseCodexPtyStatus, - stripCodexPtyControlSequences -} from './codex-pty-status-parser' - -const PTY_TIMEOUT_MS = 15_000 -const PTY_STATUS_NUDGE_MS = 2_500 -const PTY_STATUS_ENTER_DELAY_MS = 350 -const PTY_STATUS_ENTER_RETRY_MS = 3_000 -const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 - -export type CodexPtyRateLimitCommand = { - command: string - args: string[] - cwd: string - env: NodeJS.ProcessEnv -} - -export async function fetchCodexRateLimitsViaPty( - resolveCommand: () => CodexPtyRateLimitCommand, - options?: CodexRateLimitFetchOptions -): Promise { - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const command = resolveCommand() - - return new Promise((resolve) => { - let output = '' - let resolved = false - let sentStatus = false - let settleTimer: ReturnType | null = null - let timeout: ReturnType | null = null - - const term = pty.spawn(command.command, command.args, { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: command.cwd, - env: command.env - }) - const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - - let statusEnter: ReturnType | null = null - let statusNudge: ReturnType | null = null - function sendStatusCommand(): void { - sentStatus = true - if (statusNudge) { - clearTimeout(statusNudge) - statusNudge = null - } - term.write('/status') - statusEnter = setTimeout(() => { - statusEnter = null - term.write('\r') - statusEnter = setTimeout(() => { - statusEnter = null - if (!resolved && !settleTimer) { - term.write('\r') - } - }, PTY_STATUS_ENTER_RETRY_MS) - }, PTY_STATUS_ENTER_DELAY_MS) - } - - function armStatusNudge(): void { - if (statusNudge || sentStatus || resolved) { - return - } - statusNudge = setTimeout(() => { - statusNudge = null - if (!resolved && !sentStatus) { - sendStatusCommand() - } - }, PTY_STATUS_NUDGE_MS) - } - termDisposables.push({ - dispose: () => { - if (statusNudge) { - clearTimeout(statusNudge) - statusNudge = null - } - if (statusEnter) { - clearTimeout(statusEnter) - statusEnter = null - } - } - }) - - function clearSettleTimers(): void { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - if (settleTimer) { - clearTimeout(settleTimer) - settleTimer = null - } - } - - function settleAborted(): void { - if (resolved) { - return - } - resolved = true - clearSettleTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve(abortedCodexRateLimitResult()) - } - - if (options?.signal) { - if (options.signal.aborted) { - settleAborted() - return - } - options.signal.addEventListener('abort', settleAborted, { once: true }) - termDisposables.push({ - dispose: () => options.signal?.removeEventListener('abort', settleAborted) - }) - } - - timeout = setTimeout(() => { - if (!resolved) { - resolved = true - clearSettleTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: extractCodexAuthError(output) ?? withMacTailscaleDnsHint('PTY timeout', output), - status: 'error' - }) - } - }, PTY_TIMEOUT_MS) - - const onDataDisposable = term.onData((data) => { - output += data - if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { - output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) - } - - const authError = extractCodexAuthError(output) - if (authError) { - resolved = true - clearSettleTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: authError, - status: 'error' - }) - return - } - - armStatusNudge() - if (!sentStatus && /[>›]\s*$/.test(data)) { - sendStatusCommand() - return - } - const probe = sentStatus && !settleTimer ? stripCodexPtyControlSequences(output) : null - if (probe !== null && hasCodexPtyRateLimit(probe)) { - settleTimer = setTimeout(() => { - settleTimer = null - if (resolved) { - return - } - resolved = true - clearSettleTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - const clean = stripCodexPtyControlSequences(output) - const { session, weekly } = parseCodexPtyStatus(clean) - resolve({ - provider: 'codex', - session, - weekly, - updatedAt: Date.now(), - error: - session || weekly - ? null - : withMacTailscaleDnsHint('Failed to parse CLI output', clean), - status: session || weekly ? 'ok' : 'error' - }) - }, 500) - } - }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } - - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - if (settleTimer) { - clearTimeout(settleTimer) - settleTimer = null - } - if (!resolved) { - resolved = true - if (timeout) { - clearTimeout(timeout) - } - const clean = stripCodexPtyControlSequences(output) - const { session, weekly } = parseCodexPtyStatus(clean) - resolve({ - provider: 'codex', - session, - weekly, - updatedAt: Date.now(), - error: - session || weekly - ? null - : (extractCodexAuthError(clean) ?? - withMacTailscaleDnsHint('CLI exited before status was available', clean)), - status: session || weekly ? 'ok' : 'error' - }) - } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) - } - }) -} diff --git a/src/main/rate-limits/codex-pty-status-parser.test.ts b/src/main/rate-limits/codex-pty-status-parser.test.ts deleted file mode 100644 index 602c67600ca..00000000000 --- a/src/main/rate-limits/codex-pty-status-parser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseCodexPtyStatus } from './codex-pty-status-parser' - -describe('Codex PTY status parser', () => { - it('uses the account row orientation and reset when a model-scoped weekly row renders first', () => { - const result = parseCodexPtyStatus( - 'GPT-5.3-Codex-Spark Weekly limit: 100% left (resets in 1d 2h)\n' + - 'Weekly limit: 43% left (resets in 5d 3h)\n' - ) - - expect(result).toMatchObject({ - session: null, - weekly: { - usedPercent: 57, - resetDescription: '5d 3h' - } - }) - }) -}) diff --git a/src/main/rate-limits/codex-pty-status-parser.ts b/src/main/rate-limits/codex-pty-status-parser.ts deleted file mode 100644 index d298d3503c3..00000000000 --- a/src/main/rate-limits/codex-pty-status-parser.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' - -// Why: reject model-scoped rows regardless of row order in cursor-positioned output. -const FIVE_HOUR_RE = /(? ANY_LIMIT_LABEL_RE.test(line) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => FIVE_HOUR_RE.test(line), - isLimitLabel - ) - const weeklyReset = extractClaudePtyResetMetadata( - lines, - (line) => WEEKLY_RE.test(line), - isLimitLabel - ) - - return { - session: fiveMatch - ? { - usedPercent: ptyUsedPercent(fiveMatch), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null, - weekly: weeklyMatch - ? { - usedPercent: ptyUsedPercent(weeklyMatch), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - } -} diff --git a/src/main/rate-limits/codex-rate-limit-fetch-options.ts b/src/main/rate-limits/codex-rate-limit-fetch-options.ts index 1c13496644c..a130dfe8289 100644 --- a/src/main/rate-limits/codex-rate-limit-fetch-options.ts +++ b/src/main/rate-limits/codex-rate-limit-fetch-options.ts @@ -1,5 +1,4 @@ export type CodexRateLimitFetchOptions = { codexHomePath?: string | null - allowPtyFallback?: boolean signal?: AbortSignal } diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 23f0a0e6bba..7db0a60d661 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -144,7 +144,6 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledWith( expect.objectContaining({ codexHomePath: wslCodexHome, - allowPtyFallback: false, signal: expect.any(AbortSignal) }) ) diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 51ce2db3d0b..a24322a5471 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -127,7 +127,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul try { fresh = await fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), signal: controller.signal }) } catch (error) { @@ -172,11 +171,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } } - protected shouldAllowCodexPtyFallback(): boolean { - // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. - return process.platform !== 'win32' - } - protected shouldAllowClaudePtyFallback( authPreparation: ClaudeRuntimeAuthPreparation | undefined ): boolean { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 55a2cc2a062..96faa82eb34 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -183,7 +183,6 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : (missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), signal })), fetchGeminiRateLimits(geminiCliOAuthEnabled), diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index 5d977991af4..e59d1eeb3a9 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -97,7 +97,7 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP if (accounts.length === 0) { return } - // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. + // Why: account switching can activate a previewed account while its usage fetch is still in flight; ignore stale results. const fetchGeneration = this.inactiveCodexAccountsGeneration const controller = this.beginFetchCycle() const signal = controller.signal @@ -143,10 +143,8 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP this.pushToRenderer() try { // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. - // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. const fresh = await fetchCodexRateLimits({ codexHomePath: home.managedHomePath, - allowPtyFallback: false, signal }) if ( diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index 75c56d96e67..fdb7040b58a 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -40,7 +40,6 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul ? Promise.resolve(missingWslCodexHome) : fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), signal }) ).catch((err): ProviderRateLimits => ({ diff --git a/src/main/runtime/__fixtures__/hermes-tui-ready.meta.json b/src/main/runtime/__fixtures__/hermes-tui-ready.meta.json new file mode 100644 index 00000000000..46a4b35910c --- /dev/null +++ b/src/main/runtime/__fixtures__/hermes-tui-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-23T21:11:11.946Z", + "platform": "linux", + "command": ["hermes", "--yolo", "--tui"], + "cols": 120, + "rows": 31, + "note": "Hermes Agent v0.21.4 on headless Linux, ready without a submitted prompt; command path, model label, tool inventory, and session marker anonymized.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/hermes-tui-ready.txt b/src/main/runtime/__fixtures__/hermes-tui-ready.txt new file mode 100644 index 00000000000..45aaab96a8a --- /dev/null +++ b/src/main/runtime/__fixtures__/hermes-tui-ready.txt @@ -0,0 +1 @@ +[?1003l[?1002l[?1001l[?1000l[?9l[?1006l[?1005l[?1015l[?1016l[?2029l[0'z[0'{[?2029l[?1016l[?1015l[?1006l[?1005l[?1003l[?1002l[?1001l[?1000l[?9l[?1004l[?2004l[?1049l[?25h[?1049h[?1006l[?1003l[?1002l[?1000l[?1000h[?1002h[?1003h[?1006h[?2004h[?1004h[?25l[?2004h]0;Hermes ██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗ ██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ ███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ☤ Nous Research · Messenger of the Digital Gods ─ summoning hermes… │ │ voice off ─ …ktrees/XXXXXXXXXXXXXXXXXXXX ❯Try "/help" for commands[>0q]11;?]10;?[?1006l[?1003l[?1002l[?1000l[?1000h[?1002h[?1003h[?1006h t-c-bom (…n/XXXXXXXXXXXXX) forgig session… │ │ voice of [?1000$p]1;✓]2;✓ example-modelxx · ~]1;✓]2;✓ example-modelxx · ~██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ☤ Nous Research · Messenger of the Digital Gods  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ Hermes Agent │ │ ⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀ │ │⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀│ │⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀▾ Available Tools│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀▸ Available Skills (0)│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀… tools · … skills · /help for commands│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│ │example-modelxx · Nous Research│ │workspace00│ │Session: 00000000│ ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ starting agent… │ gpt6 luna 900k │ 0s │ voice off  ─ ~]1;✓]2;✓ example-modelxx · ~│ │ │ │ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃  │ 1 session]1;✓]2;✓ example-modelxx · ~   ─ ~ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ 1 ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ 2 ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ 3 ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁▁ ▁▁ ▁▁ ▁▁]1;✓]2;✓ example-modelxx · ~]1;✓]2;✓ example-modelxx · ~ Hermes Agent v0.21.4 (2026.9.21) browser: browser_vault_enter_code, browser_vault_fill, …+3 browser-use: browser_exec clarify: clarify code_execution: execute_code delegation: delegate_task file: patch, read_file, search_files, write_file exampletools0: sample_action, sample_state, sample_entities_12, …+1 kanban: kanban_attach, kanban_attach_url, kanban_attachments, …+11 (and X more toolsets…) ▸ Available Skills (XXX) in XX categories ▸ MCP Servers (X) connected XX tools · XXX skills · X MCP · /help for commands ready │ example modelxxmax │ 3s │ voiceoff │ 1 session  4 5 6 7 8 9 10s │ voice of │ 1 sesion 1 2 3 4 5 6 7 8 9 20 \ No newline at end of file diff --git a/src/main/runtime/hermes-readiness-transcript.test.ts b/src/main/runtime/hermes-readiness-transcript.test.ts new file mode 100644 index 00000000000..5605f69f91e --- /dev/null +++ b/src/main/runtime/hermes-readiness-transcript.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') + +/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the + * same normalizer the hook relay uses — so a regression in the event mapping fails here too, + * not only in the provider's own unit test. */ +const status = (eventName: string, payload: Record = {}): string => { + const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) + expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() + return `\x1b]9999;${JSON.stringify(parsed)}\x07` +} + +const readyPane = async () => + createTranscriptPane({ + paneTitle: 'Hermes Agent', + foregroundProcess: 'hermes', + launchAgent: 'hermes', + size: { cols: 120, rows: 31 }, + data: captured + }) + +describe('Hermes TUI readiness from a captured PTY', () => { + it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { + const { runtime, handle } = await readyPane() + // What Orca's own managed plugin sends for `on_session_start`. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + + const read = vi.spyOn(runtime, 'readTerminal') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + ).resolves.toMatchObject({ satisfied: true }) + // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. + expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) + }, 5_000) + + it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { + const { runtime } = await readyPane() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = internals.leaves.values().next().value + expect(leaf).toBeDefined() + expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(internals.issueHandle(leaf), { + condition: 'tui-idle', + timeoutMs: 2_000 + }) + ).resolves.toMatchObject({ satisfied: true }) + }, 5_000) + + it('does not settle while a turn the user started is still running', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_llm_call` — the first event that means a turn actually began. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_llm_call', { user_message: 'hi' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('does not settle on a turn-end done row, only on a session boundary', async () => { + const { runtime, handle } = await readyPane() + // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('post_llm_call', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_approval_request` supersedes the boundary row the session opened with. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_approval_request', { command: 'rm -rf build' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) +}) diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 3e8d618e8a4..665a5ef2253 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -184,7 +184,11 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime pty.launchAgent ) if (payload) { - pty.lastExplicitAgentStatus = { state: payload.state, updatedAt: Date.now() } + pty.lastExplicitAgentStatus = { + state: payload.state, + updatedAt: Date.now(), + sessionBoundary: payload.sessionBoundary + } this.emitTerminalAgentStatusEvents(ptyId, { cleanData: '', payloads: [payload], diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 52c1fb32551..c3dfd7b2cac 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -232,7 +232,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution if (ptyRecord) { ptyRecord.lastExplicitAgentStatus = { state: latestAgentStatus.state, - updatedAt: Date.now() + updatedAt: Date.now(), + sessionBoundary: latestAgentStatus.sessionBoundary } } } diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d030b896da9..32ebf1a423b 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -76,7 +76,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { /** Latest first-party state from the agent's own OSC 9999 status stream — what the * agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its * OSC title. Optional: absent until a payload lands. */ - lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null + lastExplicitAgentStatus?: { + state: AgentStatusState + updatedAt: number + /** A `done` row that marks a new session owning the pane, not the end of a turn. */ + sessionBoundary?: boolean + } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 064477ae71c..3df42c82166 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -53,7 +53,11 @@ export type TuiIdleEvidenceRecord = { lastOscTitle?: string | null } -export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null +export type FirstPartyAgentStatus = { + state: AgentStatusState + updatedAt: number + sessionBoundary?: boolean +} | null /** Tier 1: an idle marker the agent put in a title itself. */ export function hasExplicitIdleTitle( @@ -88,13 +92,18 @@ export function hasExplicitIdleTitle( * Scoped rather than general: for agents whose hooks do report child turns, a `done` row * can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to * prevent. + * + * The second lane is narrower and agent-agnostic: a `sessionBoundary` row does not claim a + * turn ended, it claims a NEW SESSION owns the pane and is waiting for its first input. That + * cannot arrive mid-turn by construction — the producers only set it for a startup/resume/ + * reset boundary — so it carries no #6011 risk for any agent that emits it. */ export function hasFreshDoneFirstPartyStatus( agent: TuiAgent | null | undefined, status: FirstPartyAgentStatus, staleAfterMs = AGENT_STATUS_STALE_AFTER_MS ): boolean { - if (agent !== 'dsh' || status?.state !== 'done') { + if (status?.state !== 'done' || (agent !== 'dsh' && status.sessionBoundary !== true)) { return false } return Date.now() - status.updatedAt <= staleAfterMs diff --git a/src/main/window/clipboard-copied-file-paths.test.ts b/src/main/window/clipboard-copied-file-paths.test.ts new file mode 100644 index 00000000000..2282ecfca9c --- /dev/null +++ b/src/main/window/clipboard-copied-file-paths.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' + +function clipboardWith(formats: Record) { + return { + readBuffer: (format: string): Buffer => { + const value = formats[format] + return typeof value === 'string' ? Buffer.from(value, 'utf8') : (value ?? Buffer.alloc(0)) + } + } +} + +function filenamesPlist(paths: string[]): string { + const entries = paths.map((path) => `${path}`).join('') + return `${entries}` +} + +describe('readClipboardCopiedFilePaths', () => { + it('lists every file Finder copied, decoding XML entities', () => { + const clipboard = clipboardWith({ + NSFilenamesPboardType: filenamesPlist(['/Users/me/Q&A shot.png', '/Users/me/b.pdf']), + 'public.file-url': 'file:///Users/me/Q&A%20shot.png' + }) + expect(readClipboardCopiedFilePaths(clipboard, 'darwin')).toEqual([ + '/Users/me/Q&A shot.png', + '/Users/me/b.pdf' + ]) + }) + + it('falls back to the first file URL on macOS, but not a file-reference URL', () => { + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'public.file-url': 'file:///Users/me/my%20shot.png' }), + 'darwin' + ) + ).toEqual(['/Users/me/my shot.png']) + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'public.file-url': 'file:///.file/id=6571367.2773272' }), + 'darwin' + ) + ).toEqual([]) + }) + + it('reads a Linux file manager uri-list and rejects non-file entries', () => { + expect( + readClipboardCopiedFilePaths( + clipboardWith({ + 'text/uri-list': '# copied\r\nfile:///home/me/a.png\r\nfile:///home/me/b%20c.txt\r\n' + }), + 'linux' + ) + ).toEqual(['/home/me/a.png', '/home/me/b c.txt']) + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'text/uri-list': 'file:///home/me/a.png\nhttps://example.com/x' }), + 'linux' + ) + ).toEqual([]) + }) + + it('reads the single file Explorer copied and nothing when it copied several', () => { + const shellItems = (count: number): Buffer => { + const cida = Buffer.alloc(4 + 4 * (count + 1)) + cida.writeUInt32LE(count) + return cida + } + const explorer = (count: number) => + clipboardWith({ + FileNameW: Buffer.from('C:\\Users\\me\\shot.png\0', 'utf16le'), + 'Shell IDList Array': shellItems(count) + }) + expect(readClipboardCopiedFilePaths(explorer(1), 'win32')).toEqual(['C:\\Users\\me\\shot.png']) + expect(readClipboardCopiedFilePaths(explorer(2), 'win32')).toEqual([]) + }) + + it('returns nothing for plain text, oversized lists, or a failing clipboard', () => { + expect(readClipboardCopiedFilePaths(clipboardWith({}), 'darwin')).toEqual([]) + const huge = filenamesPlist(['/a'.padEnd(300 * 1024, 'a')]) + expect( + readClipboardCopiedFilePaths(clipboardWith({ NSFilenamesPboardType: huge }), 'darwin') + ).toEqual([]) + const failing = { + readBuffer: (): Buffer => { + throw new Error('format unavailable') + } + } + expect(readClipboardCopiedFilePaths(failing, 'linux')).toEqual([]) + }) +}) diff --git a/src/main/window/clipboard-copied-file-paths.ts b/src/main/window/clipboard-copied-file-paths.ts new file mode 100644 index 00000000000..77f74e5e34c --- /dev/null +++ b/src/main/window/clipboard-copied-file-paths.ts @@ -0,0 +1,89 @@ +import { fileURLToPath } from 'node:url' +import { readWindowsCopiedFilePath } from './clipboard-windows-image-file' + +type ClipboardFormatReader = { readBuffer: (format: string) => Buffer } + +const FILE_LIST_MAX_BYTES = 256 * 1024 +const XML_ENTITIES: Record = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + quot: '"' +} + +function readBoundedText(clipboard: ClipboardFormatReader, format: string): string { + const buffer = clipboard.readBuffer(format) + return buffer.byteLength <= FILE_LIST_MAX_BYTES ? buffer.toString('utf8') : '' +} + +function decodeXmlText(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|apos|gt|lt|quot));/g, + (_entity, decimal: string | undefined, hex: string | undefined, name: string | undefined) => + decimal + ? String.fromCodePoint(Number(decimal)) + : hex + ? String.fromCodePoint(Number.parseInt(hex, 16)) + : XML_ENTITIES[name ?? ''] + ) +} + +/** macOS/Linux file URLs; any other entry means this is not a file copy. */ +function filePathsFromUrls(urls: readonly string[]): string[] { + const paths: string[] = [] + for (const url of urls) { + // Finder can hand out file-reference URLs (/.file/id=…), which name no file. + if (!url.startsWith('file://') || url.startsWith('file:///.file/id=')) { + return [] + } + paths.push(fileURLToPath(url, { windows: false })) + } + return paths +} + +function readMacCopiedFilePaths(clipboard: ClipboardFormatReader): string[] { + // Finder's legacy filenames plist lists every copied file; public.file-url holds only the first. + const plist = readBoundedText(clipboard, 'NSFilenamesPboardType') + const listed = Array.from(plist.matchAll(/([^<]*)<\/string>/g), (match) => + decodeXmlText(match[1]) + ) + if (listed.length > 0) { + return listed + } + const url = readBoundedText(clipboard, 'public.file-url').trim() + return url ? filePathsFromUrls([url]) : [] +} + +function readLinuxCopiedFilePaths(clipboard: ClipboardFormatReader): string[] { + const urls = readBoundedText(clipboard, 'text/uri-list') + .split(/\r\n|\r|\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + return filePathsFromUrls(urls) +} + +/** + * Paths of the files a file manager copied, so a paste can tell the text that + * labels them from prompt text. A list it cannot read in full comes back empty. + */ +export function readClipboardCopiedFilePaths( + clipboard: ClipboardFormatReader, + platform: NodeJS.Platform = process.platform +): string[] { + try { + if (platform === 'darwin') { + return readMacCopiedFilePaths(clipboard) + } + if (platform === 'win32') { + const filePath = readWindowsCopiedFilePath({ + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }) + return filePath ? [filePath] : [] + } + return readLinuxCopiedFilePaths(clipboard) + } catch { + return [] + } +} diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index df91e29a2ed..9a9c50bd3af 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -106,6 +106,9 @@ describe('dashboard popout clipboard access', () => { await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow( 'Unauthorized clipboard IPC sender' ) + expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow( + 'Unauthorized clipboard IPC sender' + ) expect(() => handlers.get('clipboard:writeFile')?.(popoutEvent, { filePath: '/tmp/copied-file.txt', diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index ea337747421..1bcf1749c30 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -26,6 +26,7 @@ const { clipboardReadBufferMock, clipboardWriteTextMock, clipboardReadImageMock, + clipboardAvailableFormatsMock, clipboardWriteImageMock, clipboardWriteBufferMock, nativeImageCreateFromBufferMock, @@ -62,6 +63,7 @@ const { clipboardReadBufferMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), + clipboardAvailableFormatsMock: vi.fn(), clipboardWriteImageMock: vi.fn(), clipboardWriteBufferMock: vi.fn(), nativeImageCreateFromBufferMock: vi.fn(), @@ -109,6 +111,7 @@ vi.mock('electron', () => ({ readBuffer: clipboardReadBufferMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, + availableFormats: clipboardAvailableFormatsMock, writeImage: clipboardWriteImageMock, writeBuffer: clipboardWriteBufferMock }, @@ -552,6 +555,8 @@ describe('registerClipboardHandlers', () => { expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readImageThumbnail') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:hasImage') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readFilePaths') }) it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { @@ -874,4 +879,18 @@ describe('registerClipboardHandlers', () => { expect(nativeImageCreateFromBufferMock).toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() }) + + it.each([ + [['text/plain'], false], + [['text/plain', 'image/png'], true] + ])('reports image presence for %j from the format list without decoding', (formats, expected) => { + setTrustedClipboardRendererWebContentsId(17) + clipboardAvailableFormatsMock.mockReturnValue(formats) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence. + registerClipboardHandlers({} as never) + const probe = getRegisteredHandlers().get('clipboard:hasImage') + expect(probe?.(makeClipboardEvent())).toBe(expected) + expect(clipboardReadImageMock).not.toHaveBeenCalled() + expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow() + }) }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6a688ea3d3..e81140d734a 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -24,6 +24,7 @@ import { assertClipboardImageBase64LengthWithinLimit, assertClipboardImageByteLengthWithinLimit, assertClipboardImageDimensionsWithinLimit, + clipboardFormatsIncludeImage, type ClipboardImageThumbnail } from '../../shared/clipboard-image' import { @@ -38,6 +39,7 @@ import { } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' +import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { isDashboardPopoutRenderer } from './dashboard-popout-window' @@ -96,6 +98,8 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') ipcMain.removeHandler('clipboard:readImageThumbnail') + ipcMain.removeHandler('clipboard:hasImage') + ipcMain.removeHandler('clipboard:readFilePaths') void cleanupExpiredRemoteClipboardFiles() scheduleLegacyRemoteClipboardFileCleanup() @@ -117,6 +121,15 @@ export function registerClipboardHandlers(store: Store): void { assertTrustedClipboardSender(event) return buildClipboardImageThumbnail(clipboard.readImage()) }) + ipcMain.handle('clipboard:hasImage', (event): boolean => { + assertTrustedClipboardSender(event) + return clipboardFormatsIncludeImage(clipboard.availableFormats()) + }) + // Why: a file-manager copy also carries the files' names as text, which a paste must not type. + ipcMain.handle('clipboard:readFilePaths', (event): string[] => { + assertTrustedClipboardSender(event) + return readClipboardCopiedFilePaths(clipboard) + }) // Why: terminals need to detect clipboard images to support tools like Claude // Code that accept image input via paste. Writes the clipboard image to a // temp file and returns the path, or null if the clipboard has no image. diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts index 3c26bb70c4f..39099332958 100644 --- a/src/main/window/clipboard-windows-image-file.ts +++ b/src/main/window/clipboard-windows-image-file.ts @@ -13,7 +13,7 @@ type WindowsClipboardImageFileDeps = { openFile: (filePath: string) => Promise } -type WindowsClipboardImageFileFormats = { +export type WindowsClipboardFileFormats = { fileNameW: Buffer shellIdListArray: Buffer } @@ -65,7 +65,7 @@ function decodeFileNameW(value: Buffer): string | null { if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) { return null } - return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null + return filePath } function hasAtMostOneShellItem(value: Buffer): boolean { @@ -76,6 +76,14 @@ function hasAtMostOneShellItem(value: Buffer): boolean { return value.byteLength >= 12 && value.readUInt32LE(0) === 1 } +/** The one file Explorer copied; null when it copied none or several. */ +export function readWindowsCopiedFilePath({ + fileNameW, + shellIdListArray +}: WindowsClipboardFileFormats): string | null { + return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null +} + function readPngDimensions(source: Buffer): { height: number; width: number } | null { if ( source.byteLength < 24 || @@ -153,14 +161,11 @@ async function readStableFile( } export async function readWindowsClipboardImageFileAsPng( - { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, + formats: WindowsClipboardFileFormats, { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps ): Promise { - if (!hasAtMostOneShellItem(shellIdListArray)) { - return null - } - const filePath = decodeFileNameW(fileNameW) - if (!filePath) { + const filePath = readWindowsCopiedFilePath(formats) + if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) { return null } diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 80f2c2dd383..456adfef27a 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -91,6 +91,8 @@ export const uiClipboardAndWindowControlsApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), + readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), readClipboardImageThumbnail: (): Promise => ipcRenderer.invoke('clipboard:readImageThumbnail'), writeClipboardText: (text: string): Promise => diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index b0fa905efa7..c36896a2481 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -13,6 +13,9 @@ export type UiWindowApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }) => Promise + clipboardHasImage: () => Promise + /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ + readClipboardFilePaths: () => Promise readClipboardImageThumbnail: () => Promise writeClipboardText: (text: string) => Promise writeTerminalClipboardText: (text: string) => Promise diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts index 386be8b8220..f66096f5345 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts @@ -11,20 +11,4 @@ describe('collectBrowserPageIds identity', () => { expect(collectBrowserPageIds([])).toBe(fromUndefined) expect(fromUndefined).toEqual([]) }) - - it('still collects page ids, preferring pageIds over the active page', () => { - const ids = collectBrowserPageIds([ - { id: 'tab-1', pageIds: ['page-a', 'page-b'] }, - { id: 'tab-2', activePageId: 'page-c' }, - { id: 'tab-3' } - ]) - - expect(ids).toEqual(['page-a', 'page-b', 'page-c', 'tab-3']) - }) - - it('falls back to the active page when pageIds is present but empty', () => { - expect(collectBrowserPageIds([{ id: 'tab-1', pageIds: [], activePageId: 'page-a' }])).toEqual([ - 'page-a' - ]) - }) }) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts index 1719d82f244..5aee1227bc0 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { isEditableKeyboardTarget } from './browser-keyboard' // Why: the old fakes passed a single joined selector to `closest`, so any @@ -35,25 +35,6 @@ describe('isEditableKeyboardTarget', () => { expect(isEditableKeyboardTarget(targetInside(html))).toBe(true) }) - it('queries every editable host in one selector', () => { - const closest = vi.fn((_selector: string) => null) - isEditableKeyboardTarget({ isContentEditable: false, closest }) - - const selector = closest.mock.calls[0][0] - const tokens = selector.split(',').map((part) => part.trim()) - expect(tokens).toEqual([ - 'input', - 'textarea', - 'select', - '[contenteditable=""]', - '[contenteditable="true"]', - '.monaco-editor', - '.diff-editor', - '.rich-markdown-editor', - '.rich-markdown-editor-shell' - ]) - }) - it('falls back to isContentEditable when no host selector matches', () => { expect(isEditableKeyboardTarget({ isContentEditable: true, closest: () => null })).toBe(true) }) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts index ad4b00739f0..e348439cb3c 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts @@ -17,68 +17,4 @@ describe('getRemoteBrowserFrameStyle', () => { objectPosition: 'top left' }) }) - - it('keeps correctly sized frames filling the viewport', () => { - expect( - getRemoteBrowserFrameStyle({ - imageWidth: 958, - imageHeight: 609, - deviceWidth: 958, - deviceHeight: 609 - }) - ).toEqual({ - width: '100%', - height: '100%', - objectFit: 'fill', - objectPosition: 'top left' - }) - }) - - it('does not crop high-DPI frames with a uniform device scale', () => { - expect( - getRemoteBrowserFrameStyle({ - imageWidth: 1998, - imageHeight: 1218, - deviceWidth: 999, - deviceHeight: 609 - }) - ).toEqual({ - width: '100%', - height: '100%', - objectFit: 'fill', - objectPosition: 'top left' - }) - }) - - it('does not crop slightly uneven high-DPI frames after navigation', () => { - expect( - getRemoteBrowserFrameStyle({ - imageWidth: 3278, - imageHeight: 2070, - deviceWidth: 999, - deviceHeight: 609 - }) - ).toEqual({ - width: '100%', - height: '100%', - objectFit: 'fill', - objectPosition: 'top left' - }) - }) - - it('does not shrink malformed frame metadata below the viewport', () => { - expect( - getRemoteBrowserFrameStyle({ - imageWidth: 10, - imageHeight: 10, - deviceWidth: 958, - deviceHeight: 609 - }) - ).toEqual({ - width: '100%', - height: '100%', - objectFit: 'fill', - objectPosition: 'top left' - }) - }) }) diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts index c423cbedf82..b2180dcf70f 100644 --- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts +++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts @@ -34,20 +34,6 @@ function trimEnd(s: string): string { return s.trimEnd() } -function shouldSyncPropIntoEditor( - currentMarkdown: string, - propContent: string, - lastCommittedMarkdown: string -): boolean { - if (propContent === lastCommittedMarkdown) { - return false - } - if (currentMarkdown === propContent) { - return false - } - return true -} - /** * Simulates the onCreate flow: empty-list repair then getMarkdown(). */ @@ -134,27 +120,6 @@ describe('document soft-break round-trip', () => { }) }) -// ----------------------------------------------------------------------- -// 3. Rich editor content sync must ignore its own mount-time round-trip -// differences, but still accept genuine external file changes. -// ----------------------------------------------------------------------- -describe('content sync gating', () => { - it('does not re-sync on mount when only the normalized markdown differs', () => { - const disk = 'Line one\nLine two' - const normalizedMarkdown = simulateOnCreate(disk) - - expect(shouldSyncPropIntoEditor(normalizedMarkdown, disk, disk)).toBe(false) - }) - - it('does re-sync when disk content actually changes externally', () => { - const oldDisk = 'Line one\nLine two' - const newDisk = 'Line one\nLine two\nLine three' - const normalizedCurrentMarkdown = simulateOnCreate(oldDisk) - - expect(shouldSyncPropIntoEditor(normalizedCurrentMarkdown, newDisk, oldDisk)).toBe(true) - }) -}) - // ----------------------------------------------------------------------- // 4. Actual user edits must still be detected as dirty. // ----------------------------------------------------------------------- diff --git a/src/renderer/src/components/editor/monaco-programmatic-sync.test.ts b/src/renderer/src/components/editor/monaco-programmatic-sync.test.ts index a9618baedf1..ae7cc67d06b 100644 --- a/src/renderer/src/components/editor/monaco-programmatic-sync.test.ts +++ b/src/renderer/src/components/editor/monaco-programmatic-sync.test.ts @@ -44,13 +44,4 @@ describe('shouldIgnoreMonacoContentChange', () => { }) ).toBe(false) }) - - it('does not ignore a user edit that happens to match the saved prop content', () => { - expect( - shouldIgnoreMonacoContentChange({ - filePath: '/repo/seed.spec.ts', - isApplyingProgrammaticContent: false - }) - ).toBe(false) - }) }) diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx index 59a586e9ce8..5c608537303 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx @@ -169,7 +169,7 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', () expect(attemptsRef.current[file.id]).toBe(1) }) - it('stops after the budget and shows a truthful terminal message, then Retry re-arms', () => { + it('stops after the budget and shows a truthful terminal message', () => { const file = makeFile() const attemptsRef = { current: {} as Record } // The owner never hydrates: every retry re-fails with owner-not-ready. @@ -227,10 +227,6 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', () ) }) expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal) - - // Retry (reloadContent) clears the attempt budget for a fresh start. - delete attemptsRef.current[file.id] - expect(attemptsRef.current[file.id]).toBeUndefined() }) it('stops immediately once the read succeeds (no terminal message)', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index f6120d50861..d9fb9f1ec2d 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,5 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import { forwardRef, useCallback, useState } from 'react' import { useAppStore } from '../../store' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' @@ -14,7 +14,7 @@ import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft import { NativeChatComposerField } from './NativeChatComposerField' import type { NativeChatResolvedTarget } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' -import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' +import { useNativeChatComposerHandle } from './use-native-chat-composer-handle' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' @@ -25,7 +25,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import { useNativeChatPickerState } from './use-native-chat-picker-state' import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' -import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' import type { NativeChatComposerHandle, NativeChatComposerProps @@ -61,6 +60,7 @@ const NativeChatComposerPane = forwardRef ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }), - [focus, insertTypedText, handlePaste, pasteFromClipboard] - ) - const { pickAttachment } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths) const { toggleDictation, startHoldDictation, stopHoldDictation } = useNativeChatDictationActions({ textareaRef, setDictationPressed }) @@ -277,6 +272,7 @@ const NativeChatComposerPane = forwardRef void } +export type NativeChatDeliveryNotice = { + text: string + onRetry?: () => void + onDismiss?: () => void +} /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a @@ -185,6 +189,11 @@ export const MessageRow = memo(function MessageRow({ {deliveryNotice ? (
{deliveryNotice.text} + {deliveryNotice.onDismiss ? ( + + ) : null} {deliveryNotice.onRetry ? (
@@ -442,7 +393,8 @@ export function NativeChatResolvedView({ isWorking={isWorking} onStop={stopAgent} onOptimisticSend={onOptimisticSend} - onOptimisticSendCanceled={onOptimisticSendCanceled} + onOptimisticSendCanceled={delivery.cancel} + optimisticSendOutcome={delivery} onSlashCommand={onSlashCommand} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 29dd7f4bdf0..dd43358c30d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -268,7 +268,8 @@ export function createStructuredSessionMocks() { }, insertTypedText: () => true, handlePasteEvent: mocks.handlePasteEvent, - pasteFromClipboard: mocks.pasteFromClipboard + pasteFromClipboard: mocks.pasteFromClipboard, + contains: (node: Node | null) => fieldRef.current?.contains(node) === true })) return