Fix paste ownership, input bounds, and IPC validation

Supersedes #5745, #5746, and #5747.
This commit is contained in:
Jinwoo Hong
2026-06-19 17:14:55 -07:00
committed by GitHub
parent a5920b2183
commit 972078f2c4
592 changed files with 33301 additions and 3735 deletions
+4 -1
View File
@@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url'
// Korean key-specific overrides (reviewed in full UI context: product names, git terms, and
// labels MT mistranslated). Stored as a JSON data file — too many entries to inline under the
// .mjs max-lines limit — and loaded here so the catalog scripts keep a single ko key-override source.
const jsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'locale-ko-key-overrides.json')
const jsonPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'locale-ko-key-overrides.json'
)
let parsed
try {
+32 -31
View File
@@ -234,43 +234,44 @@ export const KO_VALUE_OVERRIDES = {
'이 워크스페이스의 최근 변경사항을 리뷰하세요. 정확성 위험, UX 회귀, 누락된 테스트 및 후속 작업에 중점을 둡니다. 보고서를 짧고 실행 가능하게 유지하세요.',
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
'작업 중단, 오래 생성된 파일, 유효성 검사 실패 및 사람의 주의가 필요한 모든 사항을 확인하세요. 실행 가능한 이슈만 보고하세요.',
'Pipeline': '파이프라인',
'Jobs': '작업',
'Completed': '완료됨',
Pipeline: '파이프라인',
Jobs: '작업',
Completed: '완료됨',
'Posting…': '게시 중…',
'thread': '스레드',
thread: '스레드',
'Close issue': '이슈 닫기',
'Close as completed': '완료로 닫기',
'Checks': '체크',
Checks: '체크',
'Refresh checks': '체크 새로고침',
'Check rerun requested': '체크 재실행이 요청되었습니다',
'Pull': '풀',
'Sync': '동기화',
Pull: '풀',
Sync: '동기화',
'Publish Branch': '브랜치 게시',
'Stage at least one file to commit': '커밋할 파일을 하나 이상 스테이지하세요',
'Commit staged changes': '스테이지된 변경 사항 커밋',
'No commits yet': '아직 커밋이 없습니다',
'Paste': '붙여넣기',
Paste: '붙여넣기',
'Next match': '다음 일치 항목',
'Previous match': '이전 일치 항목',
'Key': '키',
'Prompt': '프롬프트',
'Label': '레이블',
'Action': '작업',
'Views': '뷰',
'Jira JQL, e.g. project = ABC AND statusCategory != Done': 'Jira JQL, 예: project = ABC AND statusCategory != Done',
Key: '키',
Prompt: '프롬프트',
Label: '레이블',
Action: '작업',
Views: '뷰',
'Jira JQL, e.g. project = ABC AND statusCategory != Done':
'Jira JQL, 예: project = ABC AND statusCategory != Done',
'npm run dev': 'npm run dev',
'/goal': '/goal',
'Grab': '가져오기',
'Change': '변경',
'Intent': '의도',
Grab: '가져오기',
Change: '변경',
Intent: '의도',
'Take back': '제어권 가져오기',
'Android': 'Android',
'Network': '네트워크',
Android: 'Android',
Network: '네트워크',
'Available on': '지원 플랫폼',
'Git Bash': 'Git Bash',
'Set': '설정',
'Test': '테스트',
Set: '설정',
Test: '테스트',
'Azure DevOps': 'Azure DevOps',
'Review providers': '리뷰 제공자',
'Task providers': '작업 제공자',
@@ -278,22 +279,22 @@ export const KO_VALUE_OVERRIDES = {
'Refresh PR checks': 'PR 체크 새로고침',
'Run on': '실행 위치',
'Clone project': '프로젝트 클론',
'Smart': '스마트',
Smart: '스마트',
'Workspace name': '워크스페이스 이름',
'• inferred pricing': '• 추정 가격',
'Run context': '실행 컨텍스트',
'Dirty': '변경 있음',
'Unread': '읽지 않음',
'Home': '홈',
'Local': '로컬',
Dirty: '변경 있음',
Unread: '읽지 않음',
Home: '홈',
Local: '로컬',
'Reveal file': '파일 표시',
'new markdown': '새 Markdown',
'new shell': '새 shell',
'trash worktree': '워크트리 휴지통으로 이동',
'AI': 'AI',
'Server': '서버',
'Web': '웹',
'Code': '코드',
'Folder': '폴더',
AI: 'AI',
Server: '서버',
Web: '웹',
Code: '코드',
Folder: '폴더',
'Hermes automation created.': 'Hermes 자동화가 생성되었습니다.'
}
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { rovoPartsText } from './session-scanner-graph-parsers'
describe('AI Vault graph session parsers', () => {
it('folds large Rovo prompt parts without joining the selected text', () => {
const joinSpy = vi.spyOn(Array.prototype, 'join')
const result = rovoPartsText(
[
{ part_kind: 'tool-output', content: 'ignored' },
{ part_kind: 'user-prompt', content: 'Rovo prompt '.repeat(80) },
{ part_kind: 'text', text: 'tail' }
],
'user'
)
const joinCalls = joinSpy.mock.calls.length
expect(joinCalls).toBe(0)
expect(result?.startsWith('Rovo prompt Rovo prompt')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
})
})
@@ -128,7 +128,7 @@ export function rovoRoleFromKind(value: unknown): 'user' | 'assistant' | null {
}
export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): string | null {
const texts: string[] = []
const textParts: string[] = []
for (const part of parts) {
const record = asRecord(part)
if (!record) {
@@ -141,12 +141,17 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
if (role === 'assistant' && kind !== 'text') {
continue
}
const text = extractString(record.content) ?? extractString(record.text)
if (text) {
texts.push(text)
const text =
typeof record.content === 'string'
? record.content
: typeof record.text === 'string'
? record.text
: null
if (text !== null) {
textParts.push(text)
}
}
return normalizeTitleText(texts.join(' '))
return extractContentText(textParts)
}
export async function parseMessageGraphSessionFile(
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { extractGrokContentText } from './session-scanner-grok-parser'
describe('AI Vault Grok session parser', () => {
it('extracts bounded user_query text without trimming the full body', () => {
const trimSpy = vi.spyOn(String.prototype, 'trim')
const result = extractGrokContentText(
`<USER_INFO>context</USER_INFO><USER_QUERY>\n${'Grok prompt '.repeat(400)}</USER_QUERY>`
)
const trimCalls = trimSpy.mock.calls.length
expect(trimCalls).toBe(0)
expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
expect(result).not.toContain('USER_QUERY')
})
it('folds Grok array content without joining all text parts', () => {
const joinSpy = vi.spyOn(Array.prototype, 'join')
const result = extractGrokContentText([
{ type: 'text', text: 'Grok array '.repeat(80) },
{ type: 'text', text: 'tail' }
])
const joinCalls = joinSpy.mock.calls.length
expect(joinCalls).toBe(0)
expect(result?.startsWith('Grok array Grok array')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
})
})
@@ -13,12 +13,16 @@ import {
} from './session-scanner-accumulator'
import {
asRecord,
extractPreviewContentText,
extractString,
normalizePreviewText,
normalizeTitleText,
numberValue,
parseJsonObject
} from './session-scanner-values'
const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096
export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
@@ -79,32 +83,52 @@ async function consumeGrokChatHistory(
}
}
function extractGrokContentText(value: unknown): string | null {
const text = extractGrokRawContentText(value)
if (!text) {
return null
export function extractGrokContentText(value: unknown): string | null {
if (typeof value === 'string') {
return extractGrokStringContentText(value)
}
return text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim() || text
return extractPreviewContentText(value)
}
function extractGrokRawContentText(value: unknown): string | null {
if (typeof value === 'string') {
return extractString(value)
function extractGrokStringContentText(text: string): string | null {
const bounds = grokUserQueryEnvelopeBounds(text)
if (!bounds) {
return normalizePreviewText(text)
}
if (!Array.isArray(value)) {
const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT)
return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text)
}
function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null {
const opener = '<user_query>'
const startIndex = indexOfAsciiIgnoreCase(text, opener, 0)
if (startIndex === -1) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
const bodyStartIndex = startIndex + opener.length
const endIndex = indexOfAsciiIgnoreCase(text, '</user_query>', bodyStartIndex)
if (endIndex === -1) {
return null
}
return { start: bodyStartIndex, end: endIndex }
}
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
const lastStart = value.length - search.length
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
let matches = true
for (let offset = 0; offset < search.length; offset++) {
const code = value.charCodeAt(index + offset)
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
if (normalizedCode !== search.charCodeAt(offset)) {
matches = false
break
}
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
if (matches) {
return index
}
}
return extractString(parts.join(' '))
return -1
}
@@ -0,0 +1,306 @@
const SESSION_TITLE_TEXT_LIMIT = 96
const SESSION_PREVIEW_TEXT_LIMIT = 220
const ELLIPSIS = '...'
const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024
const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512
const FIELD_BLANK_SCAN_LIMIT = 1024
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for'
const XML_INSTRUCTIONS_PREFIX = '<INSTRUCTIONS>'
const HIDDEN_TEXT_BLOCKS = [
{ name: 'system-reminder', closeTag: '</system-reminder>' },
{ name: 'codex_internal_context', closeTag: '</codex_internal_context>' },
{ name: 'goal_context', closeTag: '</goal_context>' }
] as const
type TextBuilder = {
readonly limit: number
text: string
pendingSpace: boolean
truncated: boolean
}
export function extractMessageText(value: unknown): string | null {
const message = objectRecord(value)
return message ? extractContentText(message.content) : null
}
export function extractContentText(value: unknown): string | null {
return normalizeContentText(value, SESSION_TITLE_TEXT_LIMIT)
}
export function normalizeTitleText(value: string): string | null {
return finalizeNormalizedText(normalizeStringText(value, SESSION_TITLE_TEXT_LIMIT))
}
export function extractPreviewContentText(value: unknown): string | null {
return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT)
}
export function normalizePreviewText(value: string): string | null {
return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT))
}
function normalizeContentText(value: unknown, limit: number): string | null {
if (typeof value === 'string') {
return finalizeNormalizedText(normalizeStringText(value, limit))
}
if (!Array.isArray(value)) {
return null
}
const builder = createTextBuilder(limit)
for (const item of value) {
const text = contentItemText(item)
if (text === null) {
continue
}
appendInterPartSpace(builder)
appendNormalizedString(builder, text)
if (builder.truncated) {
break
}
}
return finalizeNormalizedText(builder)
}
function normalizeStringText(value: string, limit: number): TextBuilder {
const builder = createTextBuilder(limit)
appendNormalizedString(builder, value)
return builder
}
function createTextBuilder(limit: number): TextBuilder {
return { limit, text: '', pendingSpace: false, truncated: false }
}
function contentItemText(item: unknown): string | null {
if (typeof item === 'string') {
return item
}
const record = objectRecord(item)
if (!record) {
return null
}
return nonBlankString(record.text) ?? nonBlankString(record.content)
}
function nonBlankString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
return hasNonWhitespace(value, FIELD_BLANK_SCAN_LIMIT) ? value : null
}
function hasNonWhitespace(value: string, maxScanLength: number): boolean {
const scanLimit = Math.min(value.length, maxScanLength)
for (let index = 0; index < scanLimit; index += 1) {
if (!isWhitespaceCode(value.charCodeAt(index))) {
return true
}
}
return value.length > scanLimit
}
function appendInterPartSpace(builder: TextBuilder): void {
if (builder.text.length > 0) {
builder.pendingSpace = true
}
}
function appendNormalizedString(builder: TextBuilder, value: string): void {
let index = 0
while (index < value.length && !builder.truncated) {
const hiddenBlockEnd = hiddenTextBlockEnd(value, index)
if (hiddenBlockEnd !== null) {
if (builder.text.length > 0) {
builder.pendingSpace = true
}
index = hiddenBlockEnd
continue
}
const code = value.charCodeAt(index)
if (isWhitespaceCode(code)) {
if (builder.text.length > 0) {
builder.pendingSpace = true
}
index += 1
continue
}
if (builder.pendingSpace) {
appendVisibleText(builder, ' ')
builder.pendingSpace = false
if (builder.truncated) {
break
}
}
const charLength = codePointLength(value, index)
appendVisibleText(builder, value.slice(index, index + charLength))
index += charLength
}
}
function appendVisibleText(builder: TextBuilder, value: string): void {
builder.text += value
builder.truncated = builder.text.length > builder.limit
}
function hiddenTextBlockEnd(value: string, index: number): number | null {
if (value.charCodeAt(index) !== 60) {
return null
}
for (const block of HIDDEN_TEXT_BLOCKS) {
const nameStart = index + 1
if (!startsWithIgnoreCase(value, block.name, nameStart)) {
continue
}
const afterName = nameStart + block.name.length
if (!isTagBoundary(value.charCodeAt(afterName))) {
continue
}
const openEnd = tagEndIndex(value, afterName)
if (openEnd === null) {
// Why: malformed hidden context should not leak into AI Vault titles/previews.
return value.length
}
const closeStart = indexOfIgnoreCase(
value,
block.closeTag,
openEnd + 1,
openEnd + 1 + HIDDEN_BLOCK_CLOSE_SCAN_LIMIT
)
return closeStart === -1 ? value.length : closeStart + block.closeTag.length
}
return null
}
function tagEndIndex(value: string, fromIndex: number): number | null {
const scanEnd = Math.min(value.length, fromIndex + HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT)
for (let index = fromIndex; index < scanEnd; index += 1) {
if (value.charCodeAt(index) === 62) {
return index
}
}
return null
}
function indexOfIgnoreCase(
value: string,
search: string,
fromIndex: number,
endIndex: number
): number {
const lastStart = Math.min(value.length, endIndex, value.length - search.length + 1)
for (let index = fromIndex; index < lastStart; index += 1) {
if (startsWithIgnoreCase(value, search, index)) {
return index
}
}
return -1
}
function finalizeNormalizedText(builder: TextBuilder): string | null {
if (!builder.text) {
return null
}
if (isSuppressedContextPrefix(builder.text)) {
return null
}
return builder.truncated ? truncateWithEllipsis(builder.text, builder.limit) : builder.text
}
function isSuppressedContextPrefix(value: string): boolean {
return (
(startsWithIgnoreCase(value, AGENTS_INSTRUCTIONS_PREFIX, 0) &&
isWordBoundary(value.charCodeAt(AGENTS_INSTRUCTIONS_PREFIX.length))) ||
startsWithIgnoreCase(value, XML_INSTRUCTIONS_PREFIX, 0)
)
}
function truncateWithEllipsis(value: string, limit: number): string {
const end = Math.max(0, limit - ELLIPSIS.length)
const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end
return `${value.slice(0, safeEnd)}${ELLIPSIS}`
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function startsWithIgnoreCase(value: string, search: string, fromIndex: number): boolean {
if (fromIndex + search.length > value.length) {
return false
}
for (let index = 0; index < search.length; index += 1) {
if (
toLowerAscii(value.charCodeAt(fromIndex + index)) !== toLowerAscii(search.charCodeAt(index))
) {
return false
}
}
return true
}
function toLowerAscii(code: number): number {
return code >= 65 && code <= 90 ? code + 32 : code
}
function isTagBoundary(code: number): boolean {
return Number.isNaN(code) || code === 62 || isWhitespaceCode(code)
}
function isWordBoundary(code: number): boolean {
return Number.isNaN(code) || !isWordCode(code)
}
function isWordCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 95
)
}
function isWhitespaceCode(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
function codePointLength(value: string, index: number): number {
const code = value.charCodeAt(index)
return isHighSurrogate(code) && isLowSurrogate(value.charCodeAt(index + 1)) ? 2 : 1
}
function isHighSurrogate(code: number): boolean {
return code >= 0xd800 && code <= 0xdbff
}
function isLowSurrogate(code: number): boolean {
return code >= 0xdc00 && code <= 0xdfff
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import {
extractPreviewContentText,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-values'
describe('AI Vault session scanner text values', () => {
it('normalizes compact title text without surfacing hidden context blocks', () => {
expect(
normalizeTitleText(
' <system-reminder>ignore me</system-reminder>\n' +
'<goal_context>keep going</goal_context>\tFix the picker '
)
).toBe('Fix the picker')
expect(normalizeTitleText('# AGENTS.md instructions for /repo/app <INSTRUCTIONS>')).toBeNull()
expect(normalizeTitleText('<INSTRUCTIONS>Use this repo guidance')).toBeNull()
})
it('folds large preview text directly without full-string replacement', () => {
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const hiddenContext = `<codex_internal_context source="goal">${'SECRET\n'.repeat(10_000)}</codex_internal_context>`
const result = normalizePreviewText(`${hiddenContext}\nVisible preview ${'copy '.repeat(120)}`)
const replaceCalls = replaceSpy.mock.calls.length
expect(replaceCalls).toBe(0)
expect(result?.startsWith('Visible preview copy copy')).toBe(true)
expect(result).not.toContain('SECRET')
expect(result?.endsWith('...')).toBe(true)
})
it('stops reading preview array items after the bounded display text is settled', () => {
const unreadItem = {}
Object.defineProperty(unreadItem, 'text', {
get() {
throw new Error('later preview items should not be read')
}
})
const result = extractPreviewContentText([
{ type: 'text', text: 'Visible preview '.repeat(30) },
unreadItem
])
const normalizedPreview = Array.from({ length: 30 }, () => 'Visible preview').join(' ')
expect(result).toBe(`${normalizedPreview.slice(0, 217)}...`)
})
it('keeps truncation from splitting surrogate pairs', () => {
const result = normalizePreviewText(`${'a'.repeat(216)}😀tail`)
expect(result).toBe(`${'a'.repeat(216)}...`)
})
})
+8 -91
View File
@@ -2,10 +2,6 @@ import { homedir } from 'os'
import { basename, dirname, join } from 'path'
import { readFile } from 'fs/promises'
const SESSION_PREVIEW_TEXT_LIMIT = 220
const HIDDEN_USER_CONTEXT_BLOCK_PATTERN =
/<(?:codex_internal_context\b[^>]*|goal_context)>[\s\S]*?<\/(?:codex_internal_context|goal_context)>/gi
export function timestampMs(value: unknown): number {
if (typeof value === 'string') {
const parsed = Date.parse(value)
@@ -57,6 +53,14 @@ export function extractModel(value: unknown): string | null {
)
}
export {
extractContentText,
extractMessageText,
extractPreviewContentText,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-text-normalization'
export function extractGitBranch(value: unknown): string | null {
const git = asRecord(value)
if (!git) {
@@ -65,93 +69,6 @@ export function extractGitBranch(value: unknown): string | null {
return extractString(git.branch) || extractString(git.current_branch)
}
export function extractMessageText(value: unknown): string | null {
const message = asRecord(value)
if (!message) {
return null
}
return extractContentText(message.content)
}
export function extractContentText(value: unknown): string | null {
if (typeof value === 'string') {
return normalizeTitleText(value)
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
}
}
return normalizeTitleText(parts.join(' '))
}
export function normalizeTitleText(value: string): string | null {
const withoutReminders = value
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!withoutReminders) {
return null
}
if (/^# AGENTS\.md instructions for\b/i.test(withoutReminders)) {
return null
}
if (/^<INSTRUCTIONS>/i.test(withoutReminders)) {
return null
}
return withoutReminders.length > 96 ? `${withoutReminders.slice(0, 93)}...` : withoutReminders
}
export function extractPreviewContentText(value: unknown): string | null {
if (typeof value === 'string') {
return normalizePreviewText(value)
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
}
}
return normalizePreviewText(parts.join(' '))
}
export function normalizePreviewText(value: string): string | null {
const normalized = value
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return null
}
if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^<INSTRUCTIONS>/i.test(normalized)) {
return null
}
return normalized.length > SESSION_PREVIEW_TEXT_LIMIT
? `${normalized.slice(0, SESSION_PREVIEW_TEXT_LIMIT - 3)}...`
: normalized
}
export async function readJsonObjectIfExists(
filePath: string
): Promise<Record<string, unknown> | null> {
+45 -1
View File
@@ -1,13 +1,14 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
let tempRoots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
@@ -669,6 +670,49 @@ describe('scanAiVaultSessions', () => {
"cd '/tmp/kimi' && kimi --session 'session_kimi-session'"
)
})
it('strips newline-heavy Grok user_query envelopes without regex matching', async () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-large-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionDir = join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'large-session')
const requestText = 'Grok large title\n'.repeat(300)
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, 'summary.json'),
JSON.stringify({
info: { id: 'large-session', cwd: '/tmp/grok' },
created_at: '2026-05-01T10:04:00.000Z'
})
)
await writeFile(
join(sessionDir, 'chat_history.jsonl'),
jsonLines([
{
type: 'user',
content: `<USER_INFO>context</USER_INFO><USER_QUERY>\n${requestText}</USER_QUERY>`
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin',
limit: 5
})
expect(result.issues).toEqual([])
expect(result.sessions[0]?.title).toContain('Grok large title')
expect(result.sessions[0]?.title).not.toContain('USER_QUERY')
const usedGrokWrapperMatch = matchSpy.mock.calls.some(
([pattern]) =>
pattern instanceof RegExp &&
pattern.source.includes('<user_query>') &&
pattern.source.includes('[\\s\\S]')
)
expect(usedGrokWrapperMatch).toBe(false)
})
})
describe('buildAiVaultResumeCommand', () => {
@@ -143,6 +143,48 @@ Run summary: monitor automation completed successfully.
)
})
it('builds large response previews without broad regex captures', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
[
'# Cron Job: Monitor automation',
'',
'## Response',
'',
'```',
'hidden-token\n'.repeat(500),
'```',
'',
'Visible response text '.repeat(500),
''
].join('\n'),
'utf-8'
)
const { readHermesCronOutputRunsPage } = await loadReader()
const execSpy = vi.spyOn(RegExp.prototype, 'exec')
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
const usedBroadCapture = execSpy.mock.contexts.some(
(pattern) => pattern instanceof RegExp && pattern.source.includes('[\\s\\S]')
)
const usedWhitespaceReplace = replaceSpy.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+'
)
expect(usedBroadCapture).toBe(false)
expect(usedWhitespaceReplace).toBe(false)
expect((page.runs[0] as { output_preview?: string }).output_preview).toContain(
'Visible response text'
)
expect((page.runs[0] as { output_preview?: string }).output_preview).not.toContain(
'hidden-token'
)
})
it('does not hydrate referenced logs outside Hermes home', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
+204 -16
View File
@@ -16,6 +16,7 @@ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000
const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024
const FULL_SESSION_LOG_HEADING = '## Full session log'
const REFERENCED_LOG_HEADING = '## Latest log file'
const RUN_PREVIEW_LIMIT = 180
const LATEST_LOG_PATH_PATTERN =
/\bLatest log path:\s*(?<path>(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i
@@ -107,18 +108,75 @@ function escapeSqlLike(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
function cleanRunPreview(value: string): string | null {
const normalized = value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#*_`>()]/g, ' ')
.replaceAll('[', ' ')
.replaceAll(']', ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
type ContentRange = {
start: number
end: number
}
function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null {
const normalized = foldRunPreviewText(value, startIndex, endIndex)
if (!normalized.text) {
return null
}
return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized
return normalized.truncated
? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...`
: normalized.text
}
function foldRunPreviewText(
value: string,
startIndex: number,
endIndex: number
): { text: string; truncated: boolean } {
let text = ''
let pendingSpace = false
let index = Math.max(0, startIndex)
const end = Math.min(value.length, endIndex)
while (index < end && text.length <= RUN_PREVIEW_LIMIT) {
if (startsWithAt(value, '```', index)) {
if (text.length > 0) {
pendingSpace = true
}
index = skipFencedBlock(value, index, end)
continue
}
const code = value.charCodeAt(index)
if (isPreviewSeparator(code)) {
if (text.length > 0) {
pendingSpace = true
}
index += 1
continue
}
if (pendingSpace) {
text += ' '
pendingSpace = false
if (text.length > RUN_PREVIEW_LIMIT) {
break
}
}
text += value[index]
index += 1
}
return { text, truncated: text.length > RUN_PREVIEW_LIMIT }
}
function isPreviewSeparator(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 35 ||
code === 40 ||
code === 41 ||
code === 42 ||
code === 62 ||
code === 91 ||
code === 93 ||
code === 95 ||
code === 96
)
}
function parseHermesOutput(content: string): {
@@ -127,18 +185,148 @@ function parseHermesOutput(content: string): {
outputContent: string
error: string | null
} {
const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content)
const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content)
const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content)
const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null
const errorHeading = findMarkdownHeading(content, '## Error')
const responseHeading = findMarkdownHeading(content, '## Response')
const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null
const failed = hasFailedCronHeading(content) || errorHeading !== null
const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null
const previewRange = responseHeading
? { start: responseHeading.bodyStart, end: content.length }
: (errorRange ?? { start: 0, end: content.length })
return {
status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown',
outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content),
status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown',
outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end),
outputContent: content,
error
}
}
function hasFailedCronHeading(content: string): boolean {
let lineStart = 0
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (
startsWithAt(content, '#', lineStart) &&
lineContains(content, lineStart, lineEnd, 'Cron Job:') &&
lineContains(content, lineStart, lineEnd, '(FAILED)')
) {
return true
}
lineStart = nextLineStart(content, lineEnd)
}
return false
}
function findMarkdownHeading(
content: string,
heading: '## Error' | '## Response'
): { bodyStart: number } | null {
let lineStart = 0
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (
startsWithAt(content, heading, lineStart) &&
isHeadingBoundary(content.charCodeAt(lineStart + heading.length))
) {
return { bodyStart: nextLineStart(content, lineEnd) }
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function errorContentRange(content: string, bodyStart: number): ContentRange {
const start = skipPreviewWhitespace(content, bodyStart, content.length)
if (!startsWithAt(content, '```', start)) {
return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length }
}
const fencedStart = nextLineStart(content, lineEndIndex(content, start))
const fencedEnd = findClosingFence(content, fencedStart) ?? content.length
return { start: fencedStart, end: fencedEnd }
}
function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number {
const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart))
const closeStart = findClosingFence(content, bodyStart)
if (closeStart === null || closeStart > endIndex) {
return endIndex
}
return nextLineStart(content, lineEndIndex(content, closeStart))
}
function findClosingFence(content: string, fromIndex: number): number | null {
let lineStart = fromIndex
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
const textStart = skipPreviewWhitespace(content, lineStart, lineEnd)
if (startsWithAt(content, '```', textStart)) {
return textStart
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null {
let lineStart = fromIndex
while (lineStart < content.length) {
const lineEnd = lineEndIndex(content, lineStart)
if (startsWithAt(content, '## ', lineStart)) {
return lineStart
}
lineStart = nextLineStart(content, lineEnd)
}
return null
}
function lineEndIndex(value: string, startIndex: number): number {
const newline = value.indexOf('\n', startIndex)
return newline === -1 ? value.length : newline
}
function nextLineStart(value: string, lineEnd: number): number {
return lineEnd < value.length ? lineEnd + 1 : value.length
}
function lineContains(
value: string,
startIndex: number,
endIndex: number,
needle: string
): boolean {
const index = value.indexOf(needle, startIndex)
return index !== -1 && index < endIndex
}
function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number {
let index = startIndex
while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) {
index += 1
}
return index
}
function isPreviewWhitespace(code: number): boolean {
return code === 32 || (code >= 9 && code <= 13)
}
function isHeadingBoundary(code: number): boolean {
return Number.isNaN(code) || isPreviewWhitespace(code)
}
function startsWithAt(value: string, search: string, startIndex: number): boolean {
if (startIndex + search.length > value.length) {
return false
}
for (let offset = 0; offset < search.length; offset += 1) {
if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) {
return false
}
}
return true
}
function extractLatestLogPath(content: string): string | null {
const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim()
if (!rawPath) {
+111 -1
View File
@@ -51,8 +51,17 @@ vi.mock('./cdp-bridge', () => ({
}
}))
import { AgentBrowserBridge } from './agent-browser-bridge'
import {
AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES,
AgentBrowserBridge
} from './agent-browser-bridge'
import type { BrowserManager } from './browser-manager'
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
} from '../../shared/clipboard-text'
// Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId
// inside a try/catch. Override the private method to inject our mock.
@@ -1388,6 +1397,27 @@ describe('AgentBrowserBridge', () => {
expect(args).toContain('https://example.com')
})
it('rejects oversized browser clipboard writes before spawning agent-browser', async () => {
const secret = 'browser-clipboard-secret'
succeedWith({ ok: true })
await expect(
bridge.clipboardWrite(secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1))
).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects browser clipboard writes that exceed the safe agent-browser argument size', async () => {
succeedWith({ ok: true })
await expect(
bridge.clipboardWrite('x'.repeat(AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + 1))
).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
expect(execFileMock).not.toHaveBeenCalled()
})
it('builds valid fill eval JavaScript for multiline values', async () => {
succeedWith({ ok: true })
@@ -1402,6 +1432,86 @@ describe('AgentBrowserBridge', () => {
expect(() => new Function(expression)).not.toThrow()
})
it('chunks large agent-browser fill values before eval transport', async () => {
const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('')
succeedWith({ ok: true })
await bridge.fill('@textarea', text)
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
(call[1] as string[]).includes('eval')
)
const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
expect(appendExpressions).toHaveLength(2)
expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false)
expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES))
expect(appendExpressions[1]).toContain('tail')
})
it.each([
['fill', (b: AgentBrowserBridge, text: string) => b.fill('@textarea', text)],
['type', (b: AgentBrowserBridge, text: string) => b.type(text)],
['keyboard insert', (b: AgentBrowserBridge, text: string) => b.keyboardInsertText(text)]
])('yields before spawning agent-browser for accepted large %s text', async (_name, run) => {
vi.useFakeTimers()
try {
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
succeedWith({ ok: true })
const pending = run(bridge, text)
await Promise.resolve()
expect(execFileMock).not.toHaveBeenCalled()
await vi.runOnlyPendingTimersAsync()
await pending
expect(execFileMock).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('chunks large agent-browser type text before keyboard transport', async () => {
const text = ['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'].join('')
succeedWith({ typed: true })
await bridge.type(text)
const typeCalls = execFileMock.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[]
return args.includes('keyboard') && args.includes('type')
})
const chunks = typeCalls.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('type') + 1]
})
expect(chunks).toEqual(['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'])
})
it('chunks large agent-browser keyboard insert text before transport', async () => {
const text = ['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'].join('')
succeedWith({ inserted: true })
await bridge.keyboardInsertText(text)
const insertTextCalls = execFileMock.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[]
return args.includes('keyboard') && args.includes('inserttext')
})
const chunks = insertTextCalls.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('inserttext') + 1]
})
expect(chunks).toEqual(['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'])
})
// ── Cookie command arg building ──
it('builds cookie set args with all options', async () => {
+61 -9
View File
@@ -47,6 +47,8 @@ import type {
BrowserCaptureStopResult,
BrowserCookie
} from '../../shared/runtime-types'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
// Why: must exceed agent-browser's internal per-command timeouts (goto defaults to 30s,
// wait can be up to 60s). Using 90s ensures the bridge never kills a command before
@@ -55,6 +57,8 @@ const EXEC_TIMEOUT_MS = 90_000
const CONSECUTIVE_TIMEOUT_LIMIT = 3
const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000
const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000
export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024
export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
type SessionState = {
proxy: CdpWsProxy
@@ -83,6 +87,27 @@ type ResolvedBrowserCommandTarget = {
export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift'
function focusedValueSetExpression(
valueExpression: string,
options?: { append?: boolean; dispatchEvents?: boolean }
): string {
const nextValue = options?.append
? ["String(el.value ?? '') + ", valueExpression].join('')
: valueExpression
const dispatchEvents = options?.dispatchEvents
? " el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));"
: ''
return [
'(() => { const el = document.activeElement; if (el) {' +
" const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set;",
' const nextValue = ',
nextValue,
'; if (nativeSetter) { nativeSetter.call(el, nextValue); } else { el.value = nextValue; }',
dispatchEvents,
' } })()'
].join('')
}
type AgentBrowserExecOptions = {
envOverrides?: NodeJS.ProcessEnv
timeoutMs?: number
@@ -733,17 +758,30 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<BrowserFillResult> {
await assertClipboardTextWriteWithinLimitWithYield(value)
// Why: Input.insertText via Electron's debugger API does not deliver text to
// focused inputs in webviews — this is a fundamental Electron limitation.
// Agent-browser's fill and click also fail for the same reason.
// Workaround: use agent-browser's focus to resolve the ref, then set the value
// directly via JS and dispatch input/change events for React/framework compat.
// directly via chunked JS and dispatch input/change events for React/framework compat.
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
await this.execAgentBrowser(sessionName, ['focus', element])
const serializedValue = JSON.stringify(value)
await this.execAgentBrowser(sessionName, [
'eval',
`(() => { const el = document.activeElement; if (el) { const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set; if (nativeSetter) { nativeSetter.call(el, ${serializedValue}); } else { el.value = ${serializedValue}; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()`
focusedValueSetExpression(JSON.stringify(''))
])
for (const chunk of iterateBrowserTextInsertionChunks(
value,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(chunk), { append: true })
])
}
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true })
])
return { filled: element } as BrowserFillResult
})
@@ -754,12 +792,15 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<BrowserTypeResult> {
await assertClipboardTextWriteWithinLimitWithYield(input)
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, [
'keyboard',
'type',
input
])) as BrowserTypeResult
for (const chunk of iterateBrowserTextInsertionChunks(
input,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk])
}
return { typed: true } as BrowserTypeResult
})
}
@@ -836,8 +877,16 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
await assertClipboardTextWriteWithinLimitWithYield(text)
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', text])
let result: unknown = { inserted: true }
for (const chunk of iterateBrowserTextInsertionChunks(
text,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk])
}
return result
})
}
@@ -1052,6 +1101,9 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
await assertClipboardTextWriteWithinLimitWithYield(text, {
maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES
})
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text])
})
@@ -18,6 +18,7 @@ import {
importCookiesFromFile,
importCookiesFromBrowser,
detectInstalledBrowsers,
summarizeCookieImportError,
type ChromiumCookieColumnInfo,
type DetectedBrowser
} from './browser-cookie-import'
@@ -27,6 +28,19 @@ import { tmpdir } from 'node:os'
const LARGE_SAFARI_COOKIE_COUNT = 150_000
describe('summarizeCookieImportError', () => {
it('folds a bounded error preview without full-string whitespace replacement', () => {
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const message = `Import failed\n\t${'secret-cookie-value '.repeat(20_000)}`
const summary = summarizeCookieImportError(new Error(message))
expect(summary.length).toBeLessThanOrEqual(180)
expect(summary).toContain('Import failed secret-cookie-value')
expect(replaceSpy).not.toHaveBeenCalled()
})
})
function buildSafariBinaryCookies(cookieCount: number): Buffer {
const cookies: Buffer[] = []
const offsets: number[] = []
+26 -3
View File
@@ -35,9 +35,32 @@ function getDiagLogPath(): string {
function reasonWithDiagLog(reason: string): string {
return `${reason} Details were written to ${getDiagLogPath()}.`
}
function describeImportError(err: unknown): string {
const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180
const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512
// Why: imported cookie errors can include pasted or file-derived payloads;
// diagnostics only need a short preview, not a full-string whitespace pass.
export function summarizeCookieImportError(err: unknown): string {
const raw = err instanceof Error && err.message ? err.message : String(err)
return raw.replace(/\s+/g, ' ').slice(0, 180)
let summary = ''
let previousWasWhitespace = false
const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS)
for (let index = 0; index < scanLimit; index += 1) {
const code = raw.charCodeAt(index)
if (code === 32 || (code >= 9 && code <= 13)) {
if (summary.length > 0 && !previousWasWhitespace) {
summary += ' '
}
previousWasWhitespace = true
continue
}
summary += raw.charAt(index)
if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) {
return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS)
}
previousWasWhitespace = false
}
return summary
}
function diag(msg: string): void {
const line = `[${new Date().toISOString()}] ${msg}\n`
@@ -1768,7 +1791,7 @@ export async function importCookiesFromBrowser(
return {
ok: false,
reason: reasonWithDiagLog(
`Could not import cookies from ${browser.label}: ${describeImportError(err)}.`
`Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.`
)
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import {
BROWSER_TEXT_INSERT_CHUNK_BYTES,
insertTextThroughCdp,
iterateBrowserTextInsertionChunks,
splitBrowserTextInsertionChunks
} from './browser-text-insertion'
describe('browser text insertion chunking', () => {
it('keeps small text as a single CDP insertion chunk', () => {
expect(splitBrowserTextInsertionChunks('hello')).toEqual(['hello'])
})
it('splits by UTF-8 bytes without splitting surrogate pairs', () => {
const chunks = splitBrowserTextInsertionChunks('ab😀cd', 4)
expect(chunks).toEqual(['ab', '😀', 'cd'])
expect(chunks.join('')).toBe('ab😀cd')
})
it('iterates insertion chunks lazily without prebuilding the full chunk array', () => {
const chunks = iterateBrowserTextInsertionChunks('abcdefghij', 4)
expect(chunks.next()).toEqual({ done: false, value: 'abcd' })
expect(chunks.next()).toEqual({ done: false, value: 'efgh' })
expect(chunks.next()).toEqual({ done: false, value: 'ij' })
expect(chunks.next()).toEqual({ done: true, value: undefined })
})
it('keeps a single multibyte character intact when the byte cap is smaller', () => {
const chunks = splitBrowserTextInsertionChunks('😀a', 1)
expect(chunks).toEqual(['😀', 'a'])
expect(chunks.join('')).toBe('😀a')
})
it('sends bounded CDP insertText chunks in order', async () => {
const sender = vi.fn().mockResolvedValue({})
const text = 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 3)
await insertTextThroughCdp(sender, text, { yieldBetweenChunks: false })
expect(sender).toHaveBeenCalledTimes(2)
expect(sender).toHaveBeenNthCalledWith(1, 'Input.insertText', {
text: 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES)
})
expect(sender).toHaveBeenNthCalledWith(2, 'Input.insertText', { text: 'xxx' })
})
it('does not scan the full payload before the first CDP insertion resolves', async () => {
let releaseFirstChunk: (() => void) | undefined
let callCount = 0
const sender = vi.fn(() => {
callCount += 1
if (callCount === 1) {
return new Promise<void>((resolve) => {
releaseFirstChunk = resolve
})
}
return Promise.resolve()
})
const codePointAt = vi.spyOn(String.prototype, 'codePointAt')
const text = 'x'.repeat(128)
const pending = insertTextThroughCdp(sender, text, {
maxChunkBytes: 8,
yieldBetweenChunks: false
})
await Promise.resolve()
expect(sender).toHaveBeenCalledTimes(1)
expect(sender).toHaveBeenCalledWith('Input.insertText', { text: 'x'.repeat(8) })
expect(codePointAt.mock.calls.length).toBeLessThan(text.length)
releaseFirstChunk?.()
await pending
})
})
@@ -0,0 +1,77 @@
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
import type { CdpCommandSender } from './snapshot-engine'
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
function getUtf8ByteLengthForCodePoint(codePoint: number): number {
if (codePoint <= 0x7f) {
return 1
}
if (codePoint <= 0x7ff) {
return 2
}
if (codePoint <= 0xffff) {
return 3
}
return 4
}
export function splitBrowserTextInsertionChunks(
text: string,
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
): string[] {
return [...iterateBrowserTextInsertionChunks(text, maxChunkBytes)]
}
export function* iterateBrowserTextInsertionChunks(
text: string,
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
): Generator<string> {
if (text.length === 0) {
return
}
const normalizedMax = Number.isFinite(maxChunkBytes) && maxChunkBytes > 0 ? maxChunkBytes : 1
const measurement = measureClipboardTextByteLength(text, { stopAfterBytes: normalizedMax })
if (!measurement.exceededLimit) {
yield text
return
}
let currentStart = 0
let currentBytes = 0
let index = 0
while (index < text.length) {
const codePoint = text.codePointAt(index) ?? 0
const codeUnitLength = codePoint > 0xffff ? 2 : 1
const nextIndex = index + codeUnitLength
const characterBytes = getUtf8ByteLengthForCodePoint(codePoint)
if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) {
yield text.slice(currentStart, index)
currentStart = index
currentBytes = 0
}
currentBytes += characterBytes
index = nextIndex
}
if (currentStart < text.length) {
yield text.slice(currentStart)
}
}
export async function insertTextThroughCdp(
sender: CdpCommandSender,
text: string,
options?: { yieldBetweenChunks?: boolean; maxChunkBytes?: number }
): Promise<void> {
const chunks = iterateBrowserTextInsertionChunks(text, options?.maxChunkBytes)
let chunk = chunks.next()
while (!chunk.done) {
await sender('Input.insertText', { text: chunk.value })
// Why: browser automation text can be paste-sized; yielding keeps the main
// process responsive between bounded CDP payloads.
chunk = chunks.next()
if (options?.yieldBetweenChunks !== false && !chunk.done) {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
}
@@ -24,6 +24,7 @@ vi.mock('../git/worktree', () => ({
import { BrowserManager } from './browser-manager'
import { CdpBridge } from './cdp-bridge'
import { BROWSER_TEXT_INSERT_CHUNK_BYTES } from './browser-text-insertion'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { readRuntimeMetadata } from '../runtime/runtime-metadata'
@@ -474,6 +475,24 @@ describe('Browser automation pipeline (integration)', () => {
expect((res.result as { filled: string }).filled).toBe('@e2')
})
it('chunks large browser fill text before CDP insertText', async () => {
await rpc('browser.goto', { url: 'https://search.example.com' })
await rpc('browser.snapshot')
const text = 'x'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 5)
const res = await rpc('browser.fill', { element: '@e2', value: text })
const insertCalls = activeGuestHarness.sendCommandMock.mock.calls.filter(
([method]) => method === 'Input.insertText'
)
expect(res.ok).toBe(true)
expect(insertCalls).toHaveLength(2)
expect((insertCalls[0]![1] as { text: string }).text).toHaveLength(
BROWSER_TEXT_INSERT_CHUNK_BYTES
)
expect((insertCalls[1]![1] as { text: string }).text).toBe('xxxxx')
})
// ── Type ──
it('types text at current focus', async () => {
@@ -482,6 +501,21 @@ describe('Browser automation pipeline (integration)', () => {
expect((res.result as { typed: boolean }).typed).toBe(true)
})
it('chunks large browser type text before CDP insertText', async () => {
const text = 'y'.repeat(BROWSER_TEXT_INSERT_CHUNK_BYTES + 2)
const res = await rpc('browser.type', { input: text })
const insertCalls = activeGuestHarness.sendCommandMock.mock.calls.filter(
([method]) => method === 'Input.insertText'
)
expect(res.ok).toBe(true)
expect(insertCalls).toHaveLength(2)
expect((insertCalls[0]![1] as { text: string }).text).toHaveLength(
BROWSER_TEXT_INSERT_CHUNK_BYTES
)
expect((insertCalls[1]![1] as { text: string }).text).toBe('yy')
})
// ── Select ──
it('selects a dropdown option by ref', async () => {
+3 -2
View File
@@ -45,6 +45,7 @@ import {
type RefEntry,
type SnapshotResult
} from './snapshot-engine'
import { insertTextThroughCdp } from './browser-text-insertion'
import type { BrowserManager } from './browser-manager'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
@@ -337,7 +338,7 @@ export class CdpBridge {
await sender('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Delete' })
await sender('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Delete' })
await sender('Input.insertText', { text: value })
await insertTextThroughCdp(sender, value)
// Why: React and other frameworks use synthetic event listeners that may not
// detect native keyboard events. Explicitly dispatching input/change ensures
@@ -366,7 +367,7 @@ export class CdpBridge {
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
await sender('Input.insertText', { text: input })
await insertTextThroughCdp(sender, input)
return { typed: true }
})
}
+15 -3
View File
@@ -128,12 +128,16 @@ describe('buildGuestOverlayScript', () => {
expect(script).toContain('!SAFE_URL_PROTOCOLS.has(u.protocol)')
})
it('arm script slices text nodes before normalizing bounded text', () => {
it('arm script folds bounded text without joining text-node chunks', () => {
const script = buildGuestOverlayScript('arm')
expect(script).toContain("normalizeText((node.nodeValue || '').slice(0, remaining))")
expect(script).toContain(
"appendNormalizedText(acc, (node.nodeValue || '').slice(0, remaining), max)"
)
expect(script).toContain('appendNormalizedText(acc, value, BUDGET.selectedTextMaxLength)')
expect(script).toContain('value = value.slice(start, end)')
expect(script).not.toContain('normalizeText(node.nodeValue ||')
expect(script).not.toContain("chunks.join(' ')")
expect(script).not.toContain('replace(/\\s+/g')
expect(script).not.toContain("(el.textContent || '').trim()")
expect(script).not.toContain('ref.textContent')
})
@@ -145,4 +149,12 @@ describe('buildGuestOverlayScript', () => {
expect(script).toContain('nextElementSibling')
expect(script).not.toContain('Array.from(parent.children)')
})
it('arm script tokenizes aria-labelledby without regex splitting', () => {
const script = buildGuestOverlayScript('arm')
expect(script).toContain('getAriaLabelledByIds')
expect(script).toContain('isAriaLabelledBySeparator')
expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)')
})
})
+88 -26
View File
@@ -136,30 +136,58 @@ const ARM_SCRIPT = `(function() {
}
}
function normalizeText(text) {
return String(text || '').trim().replace(/\\s+/g, ' ');
function createTextAccumulator() {
return { text: '', pendingSpace: false };
}
function isWhitespaceCode(code) {
return code === 32 || (code >= 9 && code <= 13) || code === 160 ||
code === 5760 || (code >= 8192 && code <= 8202) || code === 8232 ||
code === 8233 || code === 8239 || code === 8287 || code === 12288 ||
code === 65279;
}
function appendTextSeparator(acc) {
if (acc.text.length > 0) acc.pendingSpace = true;
}
function appendNormalizedText(acc, text, max) {
var limit = max + 20;
var value = String(text || '');
for (var i = 0; i < value.length && acc.text.length < limit; i++) {
var code = value.charCodeAt(i);
if (isWhitespaceCode(code)) {
if (acc.text.length > 0) acc.pendingSpace = true;
continue;
}
if (acc.pendingSpace) {
acc.text += ' ';
acc.pendingSpace = false;
if (acc.text.length >= limit) break;
}
acc.text += value.charAt(i);
}
}
function finishAccumulatedText(acc, max) {
return clampStr(acc.text, max);
}
function getBoundedText(el, max) {
try {
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var chunks = [];
var length = 0;
var acc = createTextAccumulator();
var inspected = 0;
var node = walker.nextNode();
while (node && length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) {
while (node && acc.text.length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) {
inspected++;
var separatorLength = chunks.length > 0 ? 1 : 0;
var remaining = max + 20 - length - separatorLength;
appendTextSeparator(acc);
var remaining = max + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0);
if (remaining <= 0) break;
var value = normalizeText((node.nodeValue || '').slice(0, remaining));
if (value) {
chunks.push(value.slice(0, remaining));
length += Math.min(value.length, remaining) + separatorLength;
}
appendNormalizedText(acc, (node.nodeValue || '').slice(0, remaining), max);
node = walker.nextNode();
}
return clampStr(normalizeText(chunks.join(' ')), max);
return finishAccumulatedText(acc, max);
} catch (e) {
return '';
}
@@ -173,10 +201,13 @@ const ARM_SCRIPT = `(function() {
try {
var selection = window.getSelection ? window.getSelection() : null;
if (!selection || selection.rangeCount === 0) return '';
var chunks = [];
var length = 0;
var acc = createTextAccumulator();
var inspected = 0;
for (var i = 0; i < selection.rangeCount && length < BUDGET.selectedTextMaxLength + 20; i++) {
for (
var i = 0;
i < selection.rangeCount && acc.text.length < BUDGET.selectedTextMaxLength + 20;
i++
) {
var range = selection.getRangeAt(i);
var walkerRoot = range.commonAncestorContainer;
var walker = document.createTreeWalker(
@@ -194,14 +225,15 @@ const ARM_SCRIPT = `(function() {
var node = walkerRoot.nodeType === Node.TEXT_NODE ? walkerRoot : walker.nextNode();
while (
node &&
length < BUDGET.selectedTextMaxLength + 20 &&
acc.text.length < BUDGET.selectedTextMaxLength + 20 &&
inspected < TEXT_NODE_SCAN_LIMIT
) {
inspected++;
var textNode = node;
var value = textNode.nodeValue || '';
var separatorLength = chunks.length > 0 ? 1 : 0;
var remaining = BUDGET.selectedTextMaxLength + 20 - length - separatorLength;
appendTextSeparator(acc);
var remaining =
BUDGET.selectedTextMaxLength + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0);
if (remaining <= 0) break;
if (value) {
var start = textNode === range.startContainer ? range.startOffset : 0;
@@ -213,16 +245,12 @@ const ARM_SCRIPT = `(function() {
start = Math.min(start, value.length);
}
value = value.slice(start, end);
value = normalizeText(value);
}
if (value) {
chunks.push(value.slice(0, remaining));
length += Math.min(value.length, remaining) + separatorLength;
appendNormalizedText(acc, value, BUDGET.selectedTextMaxLength);
}
node = walker.nextNode();
}
}
return clampStr(chunks.join(' '), BUDGET.selectedTextMaxLength);
return finishAccumulatedText(acc, BUDGET.selectedTextMaxLength);
} catch (e) {
return '';
}
@@ -263,6 +291,40 @@ const ARM_SCRIPT = `(function() {
return attrs;
}
// Why: guest pages control aria-labelledby; avoid regex splitting huge
// attributes while extracting grab payload accessibility metadata.
function getAriaLabelledByIds(value) {
var ids = [];
var tokenStart = -1;
for (var index = 0; index <= value.length; index++) {
var isEnd = index === value.length;
if (!isEnd && !isAriaLabelledBySeparator(value.charCodeAt(index))) {
if (tokenStart === -1) tokenStart = index;
continue;
}
if (tokenStart !== -1) {
ids.push(value.slice(tokenStart, index));
tokenStart = -1;
if (ids.length >= 32) break;
}
}
return ids;
}
function isAriaLabelledBySeparator(code) {
return code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279;
}
function getAccessibility(el) {
var role = el.getAttribute('role') || el.tagName.toLowerCase();
var ariaLabel = el.getAttribute('aria-label') || null;
@@ -272,7 +334,7 @@ const ARM_SCRIPT = `(function() {
if (ariaLabel) {
accessibleName = ariaLabel;
} else if (ariaLabelledBy) {
var parts = ariaLabelledBy.split(/\\s+/);
var parts = getAriaLabelledByIds(ariaLabelledBy);
var names = [];
for (var i = 0; i < parts.length; i++) {
var ref = document.getElementById(parts[i]);
@@ -0,0 +1,42 @@
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR,
assertClipboardTextWriteWithinLimit,
assertClipboardTextWriteWithinLimitWithYield,
isClipboardTextWriteTooLargeError
} from '../../shared/clipboard-text'
import { RuntimeClientError } from './runtime-client-error'
export function validateComputerClipboardPasteText(text: string): void {
try {
assertClipboardTextWriteWithinLimit(text)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
throw new RuntimeClientError('invalid_argument', CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
}
throw error
}
}
export async function validateComputerClipboardPasteTextWithYield(text: string): Promise<void> {
try {
// Why: accepted paste-sized payloads must not monopolize the main process
// while preserving the provider-facing invalid_argument error contract.
await assertClipboardTextWriteWithinLimitWithYield(text)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
throw new RuntimeClientError('invalid_argument', CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
}
throw error
}
}
export function validateComputerClipboardPasteTextWithBoundedYield(
text: string
): Promise<void> | void {
if (text.length <= CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS) {
validateComputerClipboardPasteText(text)
return
}
return validateComputerClipboardPasteTextWithYield(text)
}
@@ -2,6 +2,7 @@ import {
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
import { validateComputerClipboardPasteTextWithBoundedYield } from './computer-clipboard-paste-validation'
import { RuntimeClientError } from './runtime-client-error'
type ComputerProviderActionMethod =
@@ -15,10 +16,10 @@ type ComputerProviderActionMethod =
| 'pasteText'
| 'setValue'
export function validateComputerProviderActionParams(
export async function validateComputerProviderActionParams(
method: ComputerProviderActionMethod,
params: Record<string, unknown>
): string {
): Promise<string> {
const app = requireNonEmptyString(params, 'app')
validateWindowTarget(params)
switch (method) {
@@ -40,9 +41,11 @@ export function validateComputerProviderActionParams(
validateDragTarget(params)
return app
case 'typeText':
case 'pasteText':
requireNonEmptyString(params, 'text')
return app
case 'pasteText':
await validatePasteText(params)
return app
case 'pressKey':
validatePressKey(params)
return app
@@ -182,6 +185,11 @@ function validateHotkey(params: Record<string, unknown>): void {
}
}
function validatePasteText(params: Record<string, unknown>): Promise<void> | void {
const text = requireNonEmptyString(params, 'text')
return validateComputerClipboardPasteTextWithBoundedYield(text)
}
function requireStringAllowingEmpty(params: Record<string, unknown>, key: string): string {
const value = params[key]
if (typeof value !== 'string') {
@@ -0,0 +1,15 @@
import { validateComputerClipboardPasteTextWithBoundedYield } from './computer-clipboard-paste-validation'
export function validateComputerSidecarPasteText(
method: string,
params: unknown
): Promise<void> | void {
if (method !== 'pasteText' || !params || typeof params !== 'object') {
return
}
const text = (params as Record<string, unknown>).text
if (typeof text !== 'string') {
return
}
return validateComputerClipboardPasteTextWithBoundedYield(text)
}
@@ -1,4 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
} from '../../shared/clipboard-text'
import {
createDesktopScriptProviderClient,
expectDesktopProviderSubprocessStartCount,
@@ -175,6 +179,15 @@ describe('DesktopScriptProviderClient action errors', () => {
message: expect.stringContaining('Missing text')
}
)
await expect(
client.action('pasteText', {
app: 'Text Editor',
text: ['native-provider-secret', 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)].join('')
})
).rejects.toMatchObject({
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
})
await expect(
client.action('pressKey', { app: 'Text Editor', key: 'CmdOrCtrl+V' })
).rejects.toMatchObject({
@@ -125,7 +125,7 @@ export class DesktopScriptProviderClient {
method: NativeActionMethod,
params: Record<string, unknown>
): Promise<ComputerActionResult> {
const app = validateComputerProviderActionParams(method, params)
const app = await validateComputerProviderActionParams(method, params)
const explicitWindowId = optionalNumberParam(params, 'windowId')
const explicitWindowIndex = optionalNumberParam(params, 'windowIndex')
const current = this.snapshotStore.current(app, explicitWindowId, params)
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import {
createDesktopScriptProviderClient,
expectDesktopProviderSubprocessStartCount,
mockBridgeResponse,
resetDesktopScriptProviderTestHarness,
sampleBridgeSnapshot,
sampleCapabilities
} from './desktop-script-provider-test-harness'
describe('DesktopScriptProviderClient paste validation', () => {
afterEach(resetDesktopScriptProviderTestHarness)
it('yields while validating large accepted pasteText payloads before launching the provider', async () => {
vi.useFakeTimers()
mockBridgeResponse({
ok: true,
capabilities: sampleCapabilities()
})
mockBridgeResponse({
ok: true,
action: {
path: 'clipboard',
actionName: 'paste',
fallbackReason: null
},
snapshot: sampleBridgeSnapshot('Text Editor', 'pasted')
})
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const call = client.action('pasteText', {
app: 'Text Editor',
text,
noScreenshot: true
})
await Promise.resolve()
expectDesktopProviderSubprocessStartCount(0)
await vi.advanceTimersByTimeAsync(0)
await expect(call).resolves.toMatchObject({
action: { path: 'clipboard' }
})
expectDesktopProviderSubprocessStartCount(2)
})
})
@@ -56,7 +56,7 @@ export class MacOSNativeProviderClient {
return (await this.call('getAppState', params)) as ComputerSnapshotResult
}
async action(method: NativeActionMethod, params: unknown): Promise<ComputerActionResult> {
validateComputerProviderActionParams(
await validateComputerProviderActionParams(
method,
params && typeof params === 'object' ? (params as Record<string, unknown>) : {}
)
@@ -0,0 +1,164 @@
import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
const {
chmodSyncMock,
connectMacOSProviderSocketMock,
mkdtempSyncMock,
resolveMacOSComputerUseExecutablePathMock,
rmSyncMock,
spawnMock,
writeFileSyncMock
} = vi.hoisted(() => ({
chmodSyncMock: vi.fn(),
connectMacOSProviderSocketMock: vi.fn(),
mkdtempSyncMock: vi.fn(),
resolveMacOSComputerUseExecutablePathMock: vi.fn(),
rmSyncMock: vi.fn(),
spawnMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('fs', () => ({
chmodSync: chmodSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock
}))
vi.mock('./macos-native-provider-socket', () => ({
connectMacOSProviderSocket: connectMacOSProviderSocketMock
}))
class FakeSocket extends EventEmitter {
destroyed = false
writes: string[] = []
setEncoding(): void {}
write(line: string, callback?: (error?: Error | null) => void): boolean {
this.writes.push(line)
callback?.(null)
return true
}
end(): void {
this.destroyed = true
}
destroy(): this {
this.destroyed = true
return this
}
}
class FakeProvider extends EventEmitter {
kill = vi.fn()
unref = vi.fn()
}
async function loadClientModule() {
vi.resetModules()
return await import('./macos-native-provider-client')
}
function macOSProviderCapabilities() {
return {
platform: 'darwin',
provider: 'orca-computer-use-macos',
providerVersion: '1.0.0',
protocolVersion: 1,
supports: {
actions: {
pasteText: true
}
}
}
}
describe('MacOSNativeProviderClient paste validation', () => {
const sockets: FakeSocket[] = []
const providers: FakeProvider[] = []
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
providers.length = 0
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
spawnMock.mockImplementation(() => {
const provider = new FakeProvider()
providers.push(provider)
return provider
})
connectMacOSProviderSocketMock.mockImplementation(async () => {
const socket = new FakeSocket()
sockets.push(socket)
return socket
})
})
afterEach(() => {
chmodSyncMock.mockReset()
connectMacOSProviderSocketMock.mockReset()
mkdtempSyncMock.mockReset()
resolveMacOSComputerUseExecutablePathMock.mockReset()
rmSyncMock.mockReset()
spawnMock.mockReset()
writeFileSyncMock.mockReset()
vi.useRealTimers()
})
it('yields while validating large accepted pasteText payloads before starting the helper', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const call = client.action('pasteText', { app: 'TextEdit', text })
await Promise.resolve()
expect(providers).toHaveLength(0)
expect(sockets).toHaveLength(0)
expect(spawnMock).not.toHaveBeenCalled()
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const socket = sockets[0]!
await vi.waitFor(() => expect(socket.writes).toHaveLength(1))
const handshakeRequest = JSON.parse(socket.writes[0]!) as { id: number }
socket.emit(
'data',
`${JSON.stringify({
id: handshakeRequest.id,
ok: true,
result: macOSProviderCapabilities()
})}\n`
)
await vi.waitFor(() => expect(socket.writes).toHaveLength(2))
const actionRequest = JSON.parse(socket.writes[1]!) as { id: number }
socket.emit(
'data',
`${JSON.stringify({
id: actionRequest.id,
ok: true,
result: { action: { path: 'clipboard', actionName: 'paste' } }
})}\n`
)
await expect(call).resolves.toMatchObject({
action: { path: 'clipboard' }
})
})
})
+44
View File
@@ -1,5 +1,10 @@
import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
} from '../../shared/clipboard-text'
import {
callComputerSidecarAction,
callComputerSidecarCapabilities,
@@ -235,6 +240,45 @@ describe('computer sidecar client', () => {
})
})
it('rejects oversized pasteText payloads before forking the sidecar', async () => {
const secret = 'sidecar-secret-token'
await expect(
callComputerSidecarAction('pasteText', {
app: 'Finder',
text: secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
})
).rejects.toMatchObject({
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
})
expect(children).toHaveLength(0)
})
it('yields while validating large accepted pasteText payloads before forking', async () => {
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const call = callComputerSidecarAction('pasteText', { app: 'Finder', text })
await Promise.resolve()
expect(children).toHaveLength(0)
await vi.advanceTimersByTimeAsync(0)
expect(children).toHaveLength(1)
const child = children[0]!
const request = child.sent[0]!
child.emit('message', {
id: request.id,
ok: true,
result: { action: { path: 'clipboard', actionName: 'paste' } }
})
await expect(call).resolves.toMatchObject({
action: { path: 'clipboard' }
})
})
it('rejects queued requests after the active request times out', async () => {
const firstCall = callComputerSidecarCapabilities()
const secondCall = callComputerSidecarCapabilities()
+5
View File
@@ -8,6 +8,7 @@ import type {
ComputerSnapshotResult
} from '../../shared/runtime-types'
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
import { RuntimeClientError } from './runtime-client-error'
type ComputerSidecarMethod =
@@ -85,6 +86,10 @@ export async function callComputerSidecarAction(
>,
params: unknown
): Promise<ComputerActionResult> {
const validation = validateComputerSidecarPasteText(method, params)
if (validation) {
await validation
}
return normalizeComputerActionResult(
(await getComputerSidecar().call(method, params)) as ComputerActionResult
)
+41 -4
View File
@@ -1,14 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { basename, join } from 'path'
import { createServer, connect, type Server } from 'net'
import { DaemonServer } from './daemon-server'
import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner'
import {
getProcessStartedAtMs,
healthCheckDaemon,
killStaleDaemon,
parseLinuxBootTimeSeconds,
parseLinuxProcStartTicks,
parseDaemonPidFile,
startTimeMatches
} from './daemon-health'
@@ -58,6 +60,12 @@ function canConnect(socketPath: string): Promise<boolean> {
})
}
function daemonTestSocketPath(dir: string): string {
return process.platform === 'win32'
? `\\\\.\\pipe\\${basename(dir)}-daemon.sock`
: join(dir, 'daemon.sock')
}
describe('daemon health', () => {
let dir: string
let socketPath: string
@@ -65,7 +73,7 @@ describe('daemon health', () => {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-health-test-'))
socketPath = getDaemonSocketPath(dir)
socketPath = daemonTestSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
})
@@ -199,6 +207,35 @@ describe('parseDaemonPidFile', () => {
})
})
describe('Linux process start-time parsing', () => {
it('parses start ticks from proc stat with spaces in the command name', () => {
const fields = Array.from({ length: 20 }, (_, index) => String(index + 1))
fields[0] = 'S'
fields[19] = '987654'
expect(parseLinuxProcStartTicks(`123 (orca daemon) ${fields.join(' ')}`)).toBe(987654)
})
it('parses boot time seconds from proc stat output', () => {
expect(parseLinuxBootTimeSeconds('cpu 1 2 3\r\nbtime 1700000000\nintr 1')).toBe(1_700_000_000)
})
it('does not use line-array or whitespace-regex splitting', () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
parseLinuxProcStartTicks('123 (orca daemon) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 42')
parseLinuxBootTimeSeconds('cpu 1 2 3\nbtime 1700000000')
const usedUnboundedSplit = splitSpy.mock.calls.some(
([separator]) =>
(typeof separator === 'string' && (separator === '\n' || separator === ' ')) ||
(separator instanceof RegExp && separator.source.includes('\\s+'))
)
splitSpy.mockRestore()
expect(usedUnboundedSplit).toBe(false)
})
})
describe('startTimeMatches', () => {
it('returns true when expected is null (legacy pid file)', () => {
// The real process pid is irrelevant here — null short-circuits before
@@ -252,7 +289,7 @@ describe('killStaleDaemon pid identity guards', () => {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-health-pid-test-'))
socketPath = join(dir, 'daemon.sock')
socketPath = daemonTestSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
})
+26 -7
View File
@@ -4,6 +4,10 @@ import { execFile, execFileSync } from 'child_process'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { connect, type Socket } from 'net'
import { promisify } from 'util'
import {
getProcessOutputFields,
iterateProcessOutputLines
} from '../../shared/process-output-field-scanner'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
import { encodeNdjson } from './ndjson'
import { getDaemonPidPath } from './daemon-spawner'
@@ -316,13 +320,8 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
function getLinuxProcessStartedAtMs(pid: number): number | null {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
const afterCommand = stat.slice(stat.lastIndexOf(')') + 2)
const fields = afterCommand.split(' ')
const startTicks = Number(fields[19])
const bootTimeLine = readFileSync('/proc/stat', 'utf8')
.split('\n')
.find((line) => line.startsWith('btime '))
const bootTimeSeconds = bootTimeLine ? Number(bootTimeLine.split(/\s+/)[1]) : Number.NaN
const startTicks = parseLinuxProcStartTicks(stat)
const bootTimeSeconds = parseLinuxBootTimeSeconds(readFileSync('/proc/stat', 'utf8'))
const ticksPerSecond = Number(
execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim()
)
@@ -340,6 +339,26 @@ function getLinuxProcessStartedAtMs(pid: number): number | null {
}
}
export function parseLinuxProcStartTicks(stat: string): number {
const commandEndIndex = stat.lastIndexOf(')')
if (commandEndIndex === -1) {
return Number.NaN
}
const fields = getProcessOutputFields(stat.slice(commandEndIndex + 1), 20)
return Number(fields[19])
}
export function parseLinuxBootTimeSeconds(procStat: string): number {
for (const line of iterateProcessOutputLines(procStat)) {
if (!line.startsWith('btime ')) {
continue
}
return Number(getProcessOutputFields(line, 2)[1])
}
return Number.NaN
}
export function getProcessStartedAtMs(pid: number): number | null {
if (process.platform === 'linux') {
return getLinuxProcessStartedAtMs(pid)
+20 -8
View File
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
function expectedNativePath(posixPath: string): string {
return posixPath
}
describe('HeadlessEmulator', () => {
let emulator: HeadlessEmulator
@@ -101,30 +105,38 @@ describe('HeadlessEmulator', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file://localhost/Users/test/project\x07')
expect(emulator.getSnapshot().cwd).toBe('/Users/test/project')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/Users/test/project'))
})
it('handles OSC-7 with empty host', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///home/user/work\x07')
expect(emulator.getSnapshot().cwd).toBe('/home/user/work')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/home/user/work'))
})
it('updates CWD when new OSC-7 arrives', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///first\x07')
expect(emulator.getSnapshot().cwd).toBe('/first')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/first'))
await emulator.write('\x1b]7;file:///second\x07')
expect(emulator.getSnapshot().cwd).toBe('/second')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/second'))
})
it('keeps earlier file CWD when a later OSC-7 URI is unsupported', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///kept\x07\x1b]7;http://example.invalid/rejected\x07')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/kept'))
})
it('decodes percent-encoded paths', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///Users/test/my%20project\x07')
expect(emulator.getSnapshot().cwd).toBe('/Users/test/my project')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/Users/test/my project'))
})
it('normalizes Windows drive-letter OSC-7 paths', async () => {
@@ -163,7 +175,7 @@ describe('HeadlessEmulator', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///path/here\x1b\\')
expect(emulator.getSnapshot().cwd).toBe('/path/here')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/path/here'))
})
it('tracks OSC-7 CWD across split PTY chunks', async () => {
@@ -172,7 +184,7 @@ describe('HeadlessEmulator', () => {
await emulator.write('\x1b]7;file:///split')
await emulator.write('/project\x07')
expect(emulator.getSnapshot().cwd).toBe('/split/project')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split/project'))
})
it('tracks OSC-7 CWD when ESC and OSC marker arrive in separate chunks', async () => {
@@ -181,7 +193,7 @@ describe('HeadlessEmulator', () => {
await emulator.write('\x1b')
await emulator.write(']7;file:///split-escape\x07')
expect(emulator.getSnapshot().cwd).toBe('/split-escape')
expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split-escape'))
})
})
+5 -19
View File
@@ -3,6 +3,7 @@ import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { extractLastOscTitle } from '../../shared/agent-detection'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
import { parseFileUriPath } from './osc7-file-uri'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
@@ -189,28 +190,13 @@ export class HeadlessEmulator {
}
private scanOsc7(data: string): void {
// OSC-7 format: ESC ] 7 ; <uri> BEL or ESC ] 7 ; <uri> ST
// BEL = \x07, ST = ESC \
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
let match: RegExpExecArray | null
while ((match = osc7Re.exec(data)) !== null) {
this.parseOsc7Uri(match[1])
}
scanOsc7Uris(data, (uri) => {
this.parseOsc7Uri(uri)
})
}
private extractOscScanTail(input: string): string {
const lastOsc = input.lastIndexOf('\x1b]')
const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1
const start = Math.max(lastOsc, lastEscape)
if (start === -1) {
return ''
}
const suffix = input.slice(start)
if (suffix.includes('\x07') || suffix.includes('\x1b\\')) {
return ''
}
return suffix.slice(-OSC_SCAN_TAIL_LIMIT)
return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
}
private scanPrivateModes(data: string): void {
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { extractLastOsc7Uri, extractOscScanTail } from './osc7-uri-extraction'
afterEach(() => {
vi.restoreAllMocks()
})
describe('OSC-7 URI extraction', () => {
it('extracts BEL and ST terminated OSC-7 URIs', () => {
expect(extractLastOsc7Uri('\x1b]7;file:///first\x07noise\x1b]7;file:///second\x1b\\')).toBe(
'file:///second'
)
})
it('recovers when abandoned incomplete OSC data is followed by a fresh URI', () => {
expect(extractLastOsc7Uri('\x1b]7;file:///abandoned\x1b]7;file:///fresh\x07')).toBe(
'file:///fresh'
)
})
it('keeps only bounded incomplete OSC tail text', () => {
const tail = extractOscScanTail(`\x1b]7;file:///${'x'.repeat(10_000)}`, 128)
expect(tail).toHaveLength(128)
})
it('scans large pasted OSC-like output without regex iteration', () => {
const execSpy = vi.spyOn(RegExp.prototype, 'exec')
const data = `${'pasted \x1b]x;noise\x07 '.repeat(10_000)}\x1b]7;file:///repo\x07`
expect(extractLastOsc7Uri(data)).toBe('file:///repo')
expect(execSpy).not.toHaveBeenCalled()
})
})
+81
View File
@@ -0,0 +1,81 @@
const OSC7_PREFIX = '\x1b]7;'
const ESC_CODE_UNIT = 0x1b
const BEL_CODE_UNIT = 0x07
const BACKSLASH_CODE_UNIT = 0x5c
type Osc7ParseResult =
| { kind: 'uri'; uri: string; nextIndex: number }
| { kind: 'invalid'; nextIndex: number }
| { kind: 'incomplete' }
function parseOsc7At(data: string, index: number): Osc7ParseResult {
if (!data.startsWith(OSC7_PREFIX, index)) {
return { kind: 'invalid', nextIndex: index + 1 }
}
const uriStart = index + OSC7_PREFIX.length
for (let cursor = uriStart; cursor < data.length; cursor += 1) {
const code = data.charCodeAt(cursor)
if (code === BEL_CODE_UNIT) {
return { kind: 'uri', uri: data.slice(uriStart, cursor), nextIndex: cursor + 1 }
}
if (code !== ESC_CODE_UNIT) {
continue
}
if (data.charCodeAt(cursor + 1) === BACKSLASH_CODE_UNIT) {
return { kind: 'uri', uri: data.slice(uriStart, cursor), nextIndex: cursor + 2 }
}
return { kind: 'invalid', nextIndex: cursor }
}
return { kind: 'incomplete' }
}
export function scanOsc7Uris(data: string, onUri: (uri: string) => void): void {
if (!data.includes(OSC7_PREFIX)) {
return
}
let searchStart = 0
while (searchStart < data.length) {
const start = data.indexOf(OSC7_PREFIX, searchStart)
if (start === -1) {
break
}
const parsed = parseOsc7At(data, start)
if (parsed.kind === 'incomplete') {
break
}
if (parsed.kind === 'uri') {
onUri(parsed.uri)
searchStart = parsed.nextIndex
continue
}
searchStart = parsed.nextIndex
}
}
export function extractLastOsc7Uri(data: string): string | null {
let lastUri: string | null = null
scanOsc7Uris(data, (uri) => {
lastUri = uri
})
return lastUri
}
export function extractOscScanTail(input: string, limit: number): string {
const lastOsc = input.lastIndexOf('\x1b]')
const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1
const start = Math.max(lastOsc, lastEscape)
if (start === -1) {
return ''
}
const suffix = input.slice(start)
if (suffix.includes('\x07') || suffix.includes('\x1b\\')) {
return ''
}
return suffix.slice(-limit)
}
+13 -17
View File
@@ -19,7 +19,7 @@ import { DevinHookService } from './hook-service'
import {
getDevinConfigPath,
getDevinManagedCommand,
getDevinManagedScriptFileName
getDevinManagedScriptPath
} from './hook-settings'
describe('DevinHookService', () => {
@@ -28,12 +28,13 @@ describe('DevinHookService', () => {
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-'))
homedirMock.mockReturnValue(homeDir)
vi.stubEnv('APPDATA', join(homeDir, '.config'))
vi.stubEnv('APPDATA', join(homeDir, 'AppData', 'Roaming'))
})
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
vi.unstubAllEnvs()
rmSync(homeDir, { recursive: true, force: true })
})
@@ -42,12 +43,10 @@ describe('DevinHookService', () => {
expect(status.state).toBe('installed')
expect(status.agent).toBe('devin')
expect(status.configPath).toBe(join(homeDir, '.config', 'devin', 'config.json'))
expect(status.configPath).toBe(getDevinConfigPath())
expect(status.managedHooksPresent).toBe(true)
const config = JSON.parse(
readFileSync(join(homeDir, '.config', 'devin', 'config.json'), 'utf8')
) as {
const config = JSON.parse(readFileSync(getDevinConfigPath(), 'utf8')) as {
hooks: Record<string, { matcher?: string; hooks: { command: string }[] }[]>
agent?: { model: string }
}
@@ -63,15 +62,12 @@ describe('DevinHookService', () => {
for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) {
expect(config.hooks[eventName][0].matcher).toBeUndefined()
}
const script = readFileSync(
join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()),
'utf8'
)
const script = readFileSync(getDevinManagedScriptPath(), 'utf8')
expect(script).toContain('/hook/devin')
})
it('preserves unrelated keys in Devin config when installing hooks', () => {
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const configPath = getDevinConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
@@ -89,7 +85,7 @@ describe('DevinHookService', () => {
})
it('installs when Devin config uses JSONC comments', () => {
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const configPath = getDevinConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
@@ -107,7 +103,7 @@ describe('DevinHookService', () => {
})
it('surfaces read_config_from overlap in status detail', () => {
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const configPath = getDevinConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
@@ -134,7 +130,7 @@ describe('DevinHookService', () => {
})
it('reports not_installed when Devin config has no managed hooks', () => {
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const configPath = getDevinConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(configPath, `${JSON.stringify({ hooks: {} }, null, 2)}\n`)
@@ -152,7 +148,7 @@ describe('DevinHookService', () => {
const removed = service.remove()
expect(removed.state).toBe('not_installed')
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const configPath = getDevinConfigPath()
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
@@ -163,8 +159,8 @@ describe('DevinHookService', () => {
})
it('returns partial status when some managed hooks are missing', () => {
const configPath = join(homeDir, '.config', 'devin', 'config.json')
const scriptPath = join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName())
const configPath = getDevinConfigPath()
const scriptPath = getDevinManagedScriptPath()
const command = getDevinManagedCommand(scriptPath)
mkdirSync(dirname(configPath), { recursive: true })
mkdirSync(dirname(scriptPath), { recursive: true })
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { parseServeSimHelperProcesses } from './serve-sim-helper-processes'
describe('parseServeSimHelperProcesses', () => {
@@ -21,4 +21,24 @@ describe('parseServeSimHelperProcesses', () => {
}
])
})
it('scans ps output without line-array splitting', () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
try {
const helpers = parseServeSimHelperProcesses(
'201 /Applications/serve-sim/bin/serve-sim-bin UDID-1 --port 3100\r\n'
)
const usedOutputSplit = splitSpy.mock.calls.some(([separator]) => {
const pattern = separator as unknown
return (
pattern === '\n' ||
(pattern instanceof RegExp && (pattern.source === '\\r?\\n' || pattern.source === '\\s+'))
)
})
expect(helpers).toHaveLength(1)
expect(usedOutputSplit).toBe(false)
} finally {
splitSpy.mockRestore()
}
})
})
@@ -1,5 +1,7 @@
import { execFile } from 'child_process'
import { platform } from 'os'
import { commandContainsToken } from '../../shared/command-token-scanner'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
export type ServeSimHelperProcess = {
pid: number
@@ -25,7 +27,7 @@ function execFileText(command: string, args: string[]): Promise<string> {
export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperProcess[] {
const helpers: ServeSimHelperProcess[] = []
for (const line of psOutput.split('\n')) {
for (const line of iterateProcessOutputLines(psOutput)) {
const match = /^\s*(\d+)\s+(.+)$/.exec(line)
if (!match) {
continue
@@ -41,7 +43,7 @@ export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperPr
}
function commandTargetsDevice(command: string, deviceUdid: string): boolean {
return command.split(/\s+/).includes(deviceUdid)
return commandContainsToken(command, deviceUdid)
}
export async function listServeSimHelperProcessesForDevice(
+4 -5
View File
@@ -4,6 +4,7 @@ import { existsSync, statSync } from 'fs'
import { basename } from 'path'
import { gitExecFileSync, gitExecFileAsync } from './runner'
import type { BaseRefSearchResult } from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import {
buildHostedRemoteCommitUrl,
buildHostedRemoteFileUrl,
@@ -363,13 +364,11 @@ export function getRemoteDrift(
['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`],
gitExecOptions(repoPath, options)
)
const [aheadStr, behindStr] = stdout.trim().split(/\s+/)
const ahead = Number(aheadStr)
const behind = Number(behindStr)
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
const counts = parseGitRevListAheadBehindCounts(stdout)
if (counts.status !== 'ok') {
return null
}
return { ahead, behind }
return { ahead: counts.ahead, behind: counts.behind }
} catch {
return null
}
+19 -1
View File
@@ -1,9 +1,13 @@
// Why: covers two recent classifier fixes — Retry-After honoring on 429
// (transient detection must propagate, not silently retry on 250ms cadence)
// and stderr extraction from execFile rejections (err.message is unreliable).
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
afterEach(() => {
vi.restoreAllMocks()
})
describe('parseRetryAfterMs', () => {
it('returns null when no Retry-After is present', () => {
expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull()
@@ -17,6 +21,20 @@ describe('parseRetryAfterMs', () => {
expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000)
})
it('parses large stderr output without full-string retry-after matching', () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const stderr = `${'noise\n'.repeat(10_000)}Retry-After: 12\n`
expect(parseRetryAfterMs(stderr)).toBe(12_000)
const usedRetryAfterMatch = matchSpy.mock.calls.some(
([pattern]) =>
pattern instanceof RegExp &&
pattern.source.startsWith('retry-after:') &&
pattern.source.includes('[^\\r\\n]')
)
expect(usedRetryAfterMatch).toBe(false)
})
it('returns null for malformed values', () => {
expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull()
})
+46 -3
View File
@@ -1154,11 +1154,10 @@ export function extractExecError(err: unknown): { stderr: string; stdout: string
* and burns the retry budget. Also supports HTTP-date Retry-After values.
*/
export function parseRetryAfterMs(stderr: string): number | null {
const m = stderr.match(/retry-after:\s*([^\r\n]+)/i)
if (!m) {
const raw = findRetryAfterHeaderValue(stderr)
if (raw === null) {
return null
}
const raw = m[1].trim()
if (/^\d+$/.test(raw)) {
const seconds = Number(raw)
return Number.isFinite(seconds) ? seconds * 1000 : null
@@ -1170,6 +1169,50 @@ export function parseRetryAfterMs(stderr: string): number | null {
return Math.max(0, ts - Date.now())
}
function findRetryAfterHeaderValue(stderr: string): string | null {
const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0)
if (headerIndex === -1) {
return null
}
let valueStart = headerIndex + 'retry-after:'.length
while (valueStart < stderr.length) {
const code = stderr.charCodeAt(valueStart)
if (code !== 9 && code !== 32) {
break
}
valueStart++
}
let valueEnd = valueStart
while (valueEnd < stderr.length) {
const code = stderr.charCodeAt(valueEnd)
if (code === 10 || code === 13) {
break
}
valueEnd++
}
const value = stderr.slice(valueStart, valueEnd).trim()
return value.length > 0 ? value : null
}
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
const lastStart = value.length - search.length
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
let matches = true
for (let offset = 0; offset < search.length; offset++) {
const code = value.charCodeAt(index + offset)
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
if (normalizedCode !== search.charCodeAt(offset)) {
matches = false
break
}
}
if (matches) {
return index
}
}
return -1
}
/**
* Classify whether a gh execFile rejection is worth retrying.
*
+3 -2
View File
@@ -47,6 +47,7 @@ import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
@@ -774,8 +775,8 @@ export async function getCommitCompare(
['rev-list', '--parents', '-n', '1', commitOid],
gitOptionsForWorktree(worktreePath, options)
)
const [, firstParent] = stdout.trim().split(/\s+/)
summary.parentOid = firstParent ?? null
const firstParent = parseGitRevListFirstParentOid(stdout)
summary.parentOid = firstParent
summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree'
const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options)
+3 -7
View File
@@ -13,6 +13,7 @@ import type {
LocalBaseRefUpdateSuggestion,
RemoveWorktreeResult
} from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
@@ -148,13 +149,8 @@ function parseRemoteTrackingLocalBaseRef(
}
function parseRevListDrift(output: string): { ahead: number; behind: number } | null {
const [aheadStr, behindStr] = output.trim().split(/\s+/)
const ahead = Number(aheadStr)
const behind = Number(behindStr)
if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) {
return null
}
return { ahead, behind }
const counts = parseGitRevListAheadBehindCounts(output)
return counts.status === 'ok' ? { ahead: counts.ahead, behind: counts.behind } : null
}
async function evaluateLocalBaseRefRefreshability(
+37
View File
@@ -75,6 +75,7 @@ import {
_resetMergeQueueCacheForTests,
_resetOwnerRepoCache
} from './client'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds'
describe('listWorkItems', () => {
beforeEach(() => {
@@ -277,6 +278,28 @@ describe('listWorkItems', () => {
)
})
it('rejects oversized queries before resolving repo sources or executing gh', async () => {
const secret = 'main-github-work-items-secret'
const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
await expect(listWorkItems('/repo-root', 10, oversizedQuery)).resolves.toEqual({
items: [],
sources: {
issues: null,
prs: null,
originCandidate: null,
upstreamCandidate: null
}
})
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
expect(getOwnerRepoMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(acquireMock).not.toHaveBeenCalled()
expect(releaseMock).not.toHaveBeenCalled()
})
it('hydrates PR list rows with repository merge metadata', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
@@ -517,6 +540,20 @@ describe('listWorkItems', () => {
expect(apiPath).not.toContain('-is:merged')
})
it('returns zero for oversized count queries before resolving repo sources', async () => {
const secret = 'main-github-work-items-secret'
const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0)
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
expect(getOwnerRepoMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(acquireMock).not.toHaveBeenCalled()
expect(releaseMock).not.toHaveBeenCalled()
})
it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
+17 -2
View File
@@ -28,6 +28,7 @@ import {
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
@@ -1259,13 +1260,24 @@ export async function listWorkItems(
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListWorkItemsResult<MainWorkItem>> {
const trimmedQuery = query?.trim() ?? ''
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return {
items: [],
sources: {
issues: null,
prs: null,
originCandidate: null,
upstreamCandidate: null
}
}
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueSource(repoPath, preference, connectionId, localGitOptions),
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
])
const issueOwnerRepo = issueResolved.source
const prOwnerRepo = prResolved.source
const trimmedQuery = query?.trim() ?? ''
await acquire()
try {
// Why: errors propagate to IPC so the renderer's cross-repo aggregator can
@@ -1417,6 +1429,10 @@ export async function countWorkItems(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<number> {
const trimmedQuery = query?.trim() ?? ''
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return 0
}
const [issueResolved, prResolved] = await Promise.all([
resolveIssueSource(repoPath, preference, connectionId, localGitOptions),
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
@@ -1428,7 +1444,6 @@ export async function countWorkItems(
return 0
}
const trimmedQuery = query?.trim() ?? ''
const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null
const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery()
+39 -1
View File
@@ -8,6 +8,10 @@
// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer,
// (e) project owner/capability caches stay bounded in long sessions.
import { beforeEach, describe, expect, it } from 'vitest'
import {
GITHUB_PROJECT_REF_INPUT_MAX_BYTES,
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
} from '../../shared/github-project-ref-input'
import {
PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES,
_getProjectViewCacheSizesForTests,
@@ -21,7 +25,8 @@ import {
classifyProjectError,
isValidOwnerSlug,
isValidRepoSlug,
parseProjectPaste
parseProjectPaste,
resolveProjectRef
} from './project-view'
describe('classifyProjectError', () => {
@@ -151,6 +156,39 @@ describe('parseProjectPaste', () => {
expect(parseProjectPaste('')).toBeNull()
expect(parseProjectPaste(' ')).toBeNull()
})
it('rejects oversized valid-looking URLs without parsing the secret-bearing tail', () => {
const secret = 'project-url-secret'
const input = [
'https://github.com/orgs/acme/projects/42?',
secret,
'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES)
].join('')
expect(parseProjectPaste(input)).toBeNull()
})
})
describe('resolveProjectRef', () => {
it('rejects oversized project refs with a metadata-only validation error', async () => {
const secret = 'project-url-secret'
const input = [
'https://github.com/orgs/acme/projects/42?',
secret,
'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES)
].join('')
await expect(resolveProjectRef({ input })).resolves.toEqual({
ok: false,
error: {
type: 'validation_error',
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
}
})
await expect(resolveProjectRef({ input })).resolves.not.toMatchObject({
error: { message: expect.stringContaining(secret) }
})
})
})
describe('project view owner caches', () => {
+16 -2
View File
@@ -47,6 +47,10 @@ import type {
ResolveProjectRefArgs,
ResolveProjectRefResult
} from '../../shared/github-project-types'
import {
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
isGitHubProjectRefInputTooLarge
} from '../../shared/github-project-ref-input'
// Re-export the public API so existing call sites (`./project-view`) keep
// working unchanged. The split is internal-only.
@@ -1565,6 +1569,9 @@ export function parseProjectPaste(input: string): ParsedPaste | null {
if (!trimmed) {
return null
}
if (isGitHubProjectRefInputTooLarge(trimmed)) {
return null
}
// URL forms
const urlRe =
/^https?:\/\/github\.com\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i
@@ -1686,13 +1693,20 @@ async function resolveOwnerType(
export async function resolveProjectRef(
args: ResolveProjectRefArgs
): Promise<ResolveProjectRefResult> {
if (typeof args.input !== 'string' || !args.input.trim()) {
const input = typeof args.input === 'string' ? args.input.trim() : ''
if (!input) {
return {
ok: false,
error: { type: 'validation_error', message: 'Input required.' }
}
}
const parsed = parseProjectPaste(args.input)
if (isGitHubProjectRefInputTooLarge(input)) {
return {
ok: false,
error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR }
}
}
const parsed = parseProjectPaste(input)
if (!parsed) {
return {
ok: false,
+13
View File
@@ -361,6 +361,19 @@ describe('parseGlabApiResponse', () => {
expect(parsed.body).toBe('[]')
})
it('splits large bodies without full-output separator matching', () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const body = '[{"iid":1}]'.repeat(10_000)
const parsed = parseGlabApiResponse(`HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n${body}`)
expect(parsed.headers['x-total']).toBe('7')
expect(parsed.body).toBe(body)
const usedSeparatorMatch = matchSpy.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r?\\n\\r?\\n'
)
expect(usedSeparatorMatch).toBe(false)
})
it('lowercases header names for stable lookup', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 1\nContent-Type: application/json\n\n{}'
const parsed = parseGlabApiResponse(stdout)
+2 -24
View File
@@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response'
// Why: legacy generic execFile wrapper - only used by callers that don't need
// WSL-aware routing. Repo-scoped callers should use the runner exports below.
@@ -27,6 +28,7 @@ export type {
ProjectRef,
ResolvedIssueSource
} from './gitlab-project-ref-resolution'
export { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response'
const MAX_CONCURRENT = 4
let running = 0
@@ -53,11 +55,6 @@ export function release(): void {
}
}
export type GlabApiResponse = {
body: string
headers: Record<string, string>
}
export async function glabApiWithHeaders(
args: string[],
options?: { cwd?: string }
@@ -65,22 +62,3 @@ export async function glabApiWithHeaders(
const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options)
return parseGlabApiResponse(stdout)
}
/** @internal - exported for tests. */
export function parseGlabApiResponse(stdout: string): GlabApiResponse {
const sepMatch = stdout.match(/\r?\n\r?\n/)
if (!sepMatch || sepMatch.index === undefined) {
return { body: stdout, headers: {} }
}
const headerBlock = stdout.slice(0, sepMatch.index)
const body = stdout.slice(sepMatch.index + sepMatch[0].length)
const headers: Record<string, string> = {}
const lines = headerBlock.split(/\r?\n/)
for (const line of lines) {
const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/)
if (m) {
headers[m[1].toLowerCase()] = m[2].trim()
}
}
return { body, headers }
}
+46
View File
@@ -0,0 +1,46 @@
export type GlabApiResponse = {
body: string
headers: Record<string, string>
}
/** @internal - exported for tests through gl-utils. */
export function parseGlabApiResponse(stdout: string): GlabApiResponse {
// Why: response is HTTP status, headers, blank line, then body.
// Find the first blank line (CRLF or LF) as the boundary.
const separator = findHeaderBodySeparator(stdout)
if (!separator) {
return { body: stdout, headers: {} }
}
const headerBlock = stdout.slice(0, separator.index)
const body = stdout.slice(separator.bodyStart)
const headers: Record<string, string> = {}
// Skip the status line and parse the rest as key: value.
const lines = headerBlock.split(/\r?\n/)
for (const line of lines) {
const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/)
if (m) {
headers[m[1].toLowerCase()] = m[2].trim()
}
}
return { body, headers }
}
function findHeaderBodySeparator(stdout: string): { index: number; bodyStart: number } | null {
let lineStart = 0
for (let index = 0; index < stdout.length; index++) {
const code = stdout.charCodeAt(index)
if (code !== 10 && code !== 13) {
continue
}
const lineEnd = index
const nextLineStart =
stdout.charCodeAt(index) === 13 && stdout.charCodeAt(index + 1) === 10 ? index + 2 : index + 1
if (lineEnd === lineStart) {
return { index: lineStart, bodyStart: nextLineStart }
}
lineStart = nextLineStart
index = nextLineStart - 1
}
return null
}
+79
View File
@@ -0,0 +1,79 @@
import { glabExecFileAsync } from '../git/runner'
import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost, type ProjectRef } from './project-ref-parser'
let knownHostsCache: readonly string[] | null = null
export function rememberGlabKnownHost(host: string): void {
const normalizedHost = normalizeGitLabHost(host)
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
return
}
knownHostsCache = [...knownHostsCache, normalizedHost]
}
export async function isGlabConfiguredForRemoteHost(
repoPath: string,
projectRef: Pick<ProjectRef, 'host'>,
connectionId?: string | null
): Promise<boolean> {
try {
const result = await glabExecFileAsync(
['auth', 'status', '--hostname', projectRef.host],
connectionId ? {} : { cwd: repoPath }
)
return result !== undefined
} catch (error) {
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
const output =
[execLike.stdout, execLike.stderr, execLike.message]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n') || String(error)
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
return hosts.includes(normalizeGitLabHost(projectRef.host))
}
}
/** @internal - exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCache = null
}
export async function getGlabKnownHosts(): Promise<readonly string[]> {
if (knownHostsCache) {
return knownHostsCache
}
try {
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
// Why: glab writes auth status to stderr in some versions, stdout in
// others. Concatenate so the parser sees both.
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
// Always include gitlab.com so a fresh-install user with no auth
// still recognizes the canonical host.
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
knownHostsCache = merged
return merged
} catch {
// Auth check failed (glab not installed, no auth, etc.) - fall back
// to the canonical default. The caller will hit the auth error on
// the first real request anyway.
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
return knownHostsCache
}
}
// Why: glab auth status output is human-formatted and varies across versions.
// Match observed logged-in lines and host headers, dedupe, lowercase.
export function parseGlabAuthStatusHosts(output: string): string[] {
const hosts = new Set<string>()
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
hosts.add(m[1].toLowerCase())
}
for (const line of output.split('\n')) {
const bareLine = line.trim()
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
hosts.add(hostLine.toLowerCase())
}
}
return Array.from(hosts)
}
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(__dirname, 'index.ts'), 'utf8')
function sourceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start + startPattern.length)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('headless automation dispatcher source boundaries', () => {
it('creates new-per-run workspaces from the resolved run target repo', () => {
const createArgsSection = sourceBetween('buildHeadlessAutomationWorktreeCreateArgs({', '})')
expect(createArgsSection).toContain('repo: target.repo')
expect(createArgsSection).not.toContain('automation.sourceContext')
})
})
+78 -37
View File
@@ -33,6 +33,13 @@ vi.mock('./git/runner', () => ({
gitExecFileSync: gitExecFileSyncMock
}))
const TEST_REPO_PATH = join('/test/repo')
const TEST_WORKTREE_PATH = join('/test/worktree')
const TEST_REPO_ORCA_YAML_PATH = join(TEST_REPO_PATH, 'orca.yaml')
const TEST_WORKTREE_ORCA_YAML_PATH = join(TEST_WORKTREE_PATH, 'orca.yaml')
const TEST_ISSUE_COMMAND_PATH = join(TEST_REPO_PATH, '.orca', 'issue-command')
const TEST_GITIGNORE_PATH = join(TEST_REPO_PATH, '.gitignore')
describe('parseOrcaYaml', () => {
it('parses YAML with setup script only', () => {
const yaml = `scripts:\n setup: |\n echo "setting up"\n npm install\n`
@@ -255,51 +262,45 @@ describe('hasUnrecognizedOrcaYamlKeys', () => {
describe('readIssueCommand', () => {
it('prefers the local override over the shared orca.yaml command', async () => {
const fs = await import('fs')
const repoPath = join('/test', 'repo')
const localIssueCommandPath = join(repoPath, '.orca', 'issue-command')
const sharedConfigPath = join(repoPath, 'orca.yaml')
vi.mocked(fs.existsSync).mockImplementation(
(path) => path === localIssueCommandPath || path === sharedConfigPath
(path) => path === TEST_ISSUE_COMMAND_PATH || path === TEST_REPO_ORCA_YAML_PATH
)
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (path === localIssueCommandPath) {
if (path === TEST_ISSUE_COMMAND_PATH) {
return 'local command\n'
}
if (path === sharedConfigPath) {
if (path === TEST_REPO_ORCA_YAML_PATH) {
return 'issueCommand: |\n shared command\n'
}
return ''
})
const { readIssueCommand } = await import('./hooks')
expect(readIssueCommand(repoPath)).toEqual({
expect(readIssueCommand(TEST_REPO_PATH)).toEqual({
localContent: 'local command',
sharedContent: 'shared command',
effectiveContent: 'local command',
localFilePath: localIssueCommandPath,
localFilePath: TEST_ISSUE_COMMAND_PATH,
source: 'local'
})
})
it('falls back to the shared orca.yaml command when no local override exists', async () => {
const fs = await import('fs')
const repoPath = join('/test', 'repo')
const localIssueCommandPath = join(repoPath, '.orca', 'issue-command')
const sharedConfigPath = join(repoPath, 'orca.yaml')
vi.mocked(fs.existsSync).mockImplementation((path) => path === sharedConfigPath)
vi.mocked(fs.existsSync).mockImplementation((path) => path === TEST_REPO_ORCA_YAML_PATH)
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (path === sharedConfigPath) {
if (path === TEST_REPO_ORCA_YAML_PATH) {
return 'issueCommand: |\n shared command\n'
}
return ''
})
const { readIssueCommand } = await import('./hooks')
expect(readIssueCommand(repoPath)).toEqual({
expect(readIssueCommand(TEST_REPO_PATH)).toEqual({
localContent: null,
sharedContent: 'shared command',
effectiveContent: 'shared command',
localFilePath: localIssueCommandPath,
localFilePath: TEST_ISSUE_COMMAND_PATH,
source: 'shared'
})
})
@@ -308,48 +309,92 @@ describe('readIssueCommand', () => {
describe('writeIssueCommand', () => {
it('writes only the local override file and keeps .orca ignored locally', async () => {
const fs = await import('fs')
const repoPath = join('/test', 'repo')
const gitignorePath = join(repoPath, '.gitignore')
const localOrcaDir = join(repoPath, '.orca')
const localIssueCommandPath = join(localOrcaDir, 'issue-command')
vi.mocked(fs.existsSync).mockImplementation(
(path) => path === gitignorePath || path === localOrcaDir
(path) => path === TEST_GITIGNORE_PATH || path === join(TEST_REPO_PATH, '.orca')
)
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (path === gitignorePath) {
if (path === TEST_GITIGNORE_PATH) {
return 'node_modules/\n'
}
return ''
})
const { writeIssueCommand } = await import('./hooks')
writeIssueCommand(repoPath, 'local command')
writeIssueCommand(TEST_REPO_PATH, 'local command')
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
gitignorePath,
TEST_GITIGNORE_PATH,
'node_modules/\n.orca\n',
'utf-8'
)
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
localIssueCommandPath,
TEST_ISSUE_COMMAND_PATH,
'local command\n',
'utf-8'
)
})
it('deletes the local override when the override is cleared', async () => {
const fs = await import('fs')
const repoPath = join('/test', 'repo')
const localIssueCommandPath = join(repoPath, '.orca', 'issue-command')
const { writeIssueCommand } = await import('./hooks')
writeIssueCommand(repoPath, ' ')
const fs = await import('fs')
writeIssueCommand(TEST_REPO_PATH, ' ')
expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(localIssueCommandPath, {
expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(TEST_ISSUE_COMMAND_PATH, {
force: true
})
})
})
describe('runner script builders', () => {
it('builds Windows runners for newline-heavy scripts without line-array splitting', async () => {
const { buildWindowsRunnerScript } = await import('./hooks')
const script = `${'\r\n'.repeat(10_000)}pnpm install\r\nnpm run build\n`
const splitSpy = vi.spyOn(String.prototype, 'split')
const replaceSpy = vi.spyOn(String.prototype, 'replace')
try {
const result = buildWindowsRunnerScript(script)
expect(result.startsWith('@echo off\r\nsetlocal EnableExtensions\r\n')).toBe(true)
expect(result).toContain('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%')
expect(result).toContain('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%')
const usedLineSplit = splitSpy.mock.calls.some(
([separator]) =>
(typeof separator === 'string' && separator === '\n') ||
(separator instanceof RegExp && separator.source === '\\r?\\n')
)
const usedNewlineReplace = replaceSpy.mock.calls.some(
([pattern]) =>
pattern instanceof RegExp && (pattern.source === '\\r?\\n' || pattern.source === '\\r\\n')
)
expect(usedLineSplit).toBe(false)
expect(usedNewlineReplace).toBe(false)
} finally {
splitSpy.mockRestore()
replaceSpy.mockRestore()
}
})
it('builds POSIX runners without regex-wide CRLF normalization', async () => {
const { buildPosixRunnerScript } = await import('./hooks')
const script = `${'echo setup\r\n'.repeat(10_000)}echo done`
const replaceSpy = vi.spyOn(String.prototype, 'replace')
try {
const result = buildPosixRunnerScript(script)
expect(result.startsWith('#!/usr/bin/env bash\nset -e\necho setup\n')).toBe(true)
expect(result.endsWith('echo done\n')).toBe(true)
const usedCrlfReplace = replaceSpy.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n'
)
expect(usedCrlfReplace).toBe(false)
} finally {
replaceSpy.mockRestore()
}
})
})
describe('getEffectiveHooks', () => {
// We need to dynamically import after mocking
const makeRepo = (hookSettings?: {
@@ -386,25 +431,21 @@ describe('getEffectiveHooks', () => {
it("loads setup hooks from the target worktree's orca.yaml when a worktree path is provided", async () => {
const fs = await import('fs')
const repoPath = join('/test', 'repo')
const worktreePath = join('/test', 'worktree')
const repoConfigPath = join(repoPath, 'orca.yaml')
const worktreeConfigPath = join(worktreePath, 'orca.yaml')
vi.mocked(fs.existsSync).mockImplementation(
(path) => path === repoConfigPath || path === worktreeConfigPath
(path) => path === TEST_REPO_ORCA_YAML_PATH || path === TEST_WORKTREE_ORCA_YAML_PATH
)
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (path === repoConfigPath) {
if (path === TEST_REPO_ORCA_YAML_PATH) {
return 'scripts:\n setup: |\n echo old-version\n'
}
if (path === worktreeConfigPath) {
if (path === TEST_WORKTREE_ORCA_YAML_PATH) {
return 'scripts:\n setup: |\n echo new-version\n'
}
return ''
})
const { getEffectiveHooks } = await import('./hooks')
const result = getEffectiveHooks(makeRepo(), worktreePath)
const result = getEffectiveHooks(makeRepo(), TEST_WORKTREE_PATH)
expect(result).toEqual({
scripts: {
+52 -16
View File
@@ -147,13 +147,16 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTa
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
return content.split(/\r?\n/).some((line) => {
for (const line of iterateLfScriptLines(content)) {
// Why: bare `key:` at end-of-line (no trailing space) is valid YAML for
// a mapping with a block value on the next line. Match both forms so
// newer keys like `futureFeature:\n nested` are still detected.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
return m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])
})
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
return true
}
}
return false
} catch {
return false
}
@@ -466,13 +469,12 @@ function getHookWslContext(
}
export function buildWindowsRunnerScript(script: string): string {
const lines = script.replace(/\r?\n/g, '\n').split('\n')
const runnerLines = ['@echo off', 'setlocal EnableExtensions']
let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n'
for (const rawLine of lines) {
for (const rawLine of iterateLfScriptLines(script)) {
const command = rawLine.trim()
if (!command) {
runnerLines.push('')
runnerScript += '\r\n'
continue
}
@@ -481,11 +483,27 @@ export function buildWindowsRunnerScript(script: string): string {
// to later lines, and plain newline-separated commands also keep running
// after failures. Wrap each line in `call` and bail on non-zero exit codes
// so the generated runner matches the fail-fast behavior of `set -e`.
runnerLines.push(`call ${command}`)
runnerLines.push('if errorlevel 1 exit /b %errorlevel%')
runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n`
}
return `${runnerLines.join('\r\n')}\r\n`
return runnerScript
}
function* iterateLfScriptLines(script: string): Generator<string> {
let lineStart = 0
for (let index = 0; index < script.length; index++) {
if (script.charCodeAt(index) !== 10) {
continue
}
const lineEnd = index > lineStart && script.charCodeAt(index - 1) === 13 ? index - 1 : index
yield script.slice(lineStart, lineEnd)
lineStart = index + 1
}
if (lineStart <= script.length) {
yield script.slice(lineStart)
}
}
export function createSetupRunnerScript(
@@ -508,7 +526,28 @@ export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<
}
export function buildPosixRunnerScript(script: string): string {
return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n`
}
function normalizeCrlfScriptLineEndings(script: string): string {
let crlfStart = script.indexOf('\r\n')
if (crlfStart === -1) {
return script
}
let normalized = script.slice(0, crlfStart)
let chunkStart = crlfStart + 2
normalized += '\n'
crlfStart = script.indexOf('\r\n', chunkStart)
while (crlfStart !== -1) {
normalized += script.slice(chunkStart, crlfStart)
normalized += '\n'
chunkStart = crlfStart + 2
crlfStart = script.indexOf('\r\n', chunkStart)
}
return `${normalized}${script.slice(chunkStart)}`
}
export function createIssueCommandRunnerScript(
@@ -543,9 +582,6 @@ function createWorktreeRunnerScript(
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
const normalizedScript = useWindowsFormat
? script.replace(/\r?\n/g, '\r\n')
: script.replace(/\r\n/g, '\n')
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
@@ -565,9 +601,9 @@ function createWorktreeRunnerScript(
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (useWindowsFormat) {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(normalizedScript), 'utf-8')
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
} else {
writeFileSync(runnerScriptPath, `#!/usr/bin/env bash\nset -e\n${normalizedScript}\n`, 'utf-8')
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
// Why: chmod via UNC paths to WSL filesystem is supported by Windows and
// sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
+14 -1
View File
@@ -24,6 +24,10 @@ import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
import { startSpan } from '../observability/tracer'
import type { FeedbackDiagnosticBundleAttachment } from './feedback'
import {
assertClipboardTextWriteWithinLimit,
isClipboardTextWriteTooLargeError
} from '../../shared/clipboard-text'
const inFlightSubmissions = new Set<string>()
const submittedReportIds = new Set<string>()
@@ -414,7 +418,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
clipboard.writeText(buildUncapturedCrashReportText(args?.notes))
return { ok: true as const }
}
clipboard.writeText(formatCrashReportText(report, args?.notes))
try {
clipboard.writeText(
assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes))
)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
return { ok: false as const, error: 'Crash diagnostics are too large to copy safely.' }
}
throw error
}
return { ok: true as const }
}
)
+53 -18
View File
@@ -1,25 +1,60 @@
import { describe, expect, it } from 'vitest'
import { PASTE_PAYLOAD_CORPUS } from '../../shared/paste-payload-corpus'
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
describe('resolveLocalDroppedPathsForAgent', () => {
it('translates dropped Windows paths for local WSL worktrees', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
expect(
resolveLocalDroppedPathsForAgent(
[
'C:\\Users\\alice\\Desktop\\notes.txt',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md'
],
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
)
).toEqual(['/mnt/c/Users/alice/Desktop/notes.txt', '/home/alice/repo/README.md'])
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
function getPastePayloadCorpusText(name: string): string {
const entry = PASTE_PAYLOAD_CORPUS.find((item) => item.name === name)
if (!entry) {
throw new Error(`Missing paste payload corpus case: ${name}`)
}
return entry.text
}
function withWin32Platform<T>(callback: () => T): T {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
return callback()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
}
describe('resolveLocalDroppedPathsForAgent', () => {
it('translates only target-readable Windows paths for local WSL worktrees', () => {
const windowsPath = getPastePayloadCorpusText('Windows path with spaces')
const sameDistroWslPath = getPastePayloadCorpusText('WSL UNC path')
const otherDistroWslPath = '\\\\wsl.localhost\\Debian\\home\\user\\repo'
const uncPath = getPastePayloadCorpusText('UNC path')
const posixPath = getPastePayloadCorpusText('POSIX path with spaces')
expect(
withWin32Platform(() =>
resolveLocalDroppedPathsForAgent(
[windowsPath, sameDistroWslPath, otherDistroWslPath, uncPath, posixPath],
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo'
)
)
).toEqual([
'/mnt/c/Users/Name/My Project/file.txt',
'/home/user/repo',
otherDistroWslPath,
uncPath,
posixPath
])
})
it('translates same-distro legacy WSL UNC paths case-insensitively', () => {
expect(
withWin32Platform(() =>
resolveLocalDroppedPathsForAgent(
['\\\\wsl$\\ubuntu-24.04\\home\\user\\repo\\README.md'],
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo'
)
)
).toEqual(['/home/user/repo/README.md'])
})
it('leaves dropped paths unchanged for non-WSL worktrees', () => {
+18 -1
View File
@@ -3,5 +3,22 @@ import { parseWslPath, toLinuxPath } from '../wsl'
export function resolveLocalDroppedPathsForAgent(paths: string[], worktreePath: string): string[] {
// Why: a local WSL PTY runs inside Linux, so Windows drop paths must be
// rewritten to paths the shell and agent can read.
return parseWslPath(worktreePath) ? paths.map((droppedPath) => toLinuxPath(droppedPath)) : paths
const targetWsl = parseWslPath(worktreePath)
return targetWsl
? paths.map((droppedPath) => resolveDroppedPathForTargetWsl(droppedPath, targetWsl.distro))
: paths
}
function resolveDroppedPathForTargetWsl(droppedPath: string, targetDistro: string): string {
const droppedWsl = parseWslPath(droppedPath)
if (droppedWsl) {
// Why: WSL UNC paths are only Linux-native inside their own distro.
// Rewriting another distro would paste a plausible but wrong path.
return isSameWslDistro(droppedWsl.distro, targetDistro) ? droppedWsl.linuxPath : droppedPath
}
return toLinuxPath(droppedPath)
}
function isSameWslDistro(left: string, right: string): boolean {
return left.localeCompare(right, undefined, { sensitivity: 'accent' }) === 0
}
@@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest'
describe('PTY startup barrier ordering', () => {
it('waits for local startup before resolving the provider for runtime and renderer spawns', () => {
const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replaceAll(
'\r\n',
const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replace(
/\r\n?/g,
'\n'
)
const runtimeSpawnStart = source.indexOf('spawn: async (args) => {')
+136 -17
View File
@@ -2,11 +2,20 @@
one focused file because the registration helper is stateful and each spawn-path
assertion reuses the same mocked IPC and node-pty harness. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter, join } from 'node:path'
import { delimiter, join, posix } from 'node:path'
import {
TERMINAL_INPUT_CHUNK_MAX_BYTES,
TERMINAL_INPUT_MAX_BYTES
} from '../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
const expectedOmpStatusExtension = '/tmp/default-omp-agent/extensions/orca-agent-status.ts'
const expectedOmpStatusExtension = posix.join(
'/tmp/default-omp-agent',
'extensions',
'orca-agent-status.ts'
)
const expectedAttributionShimDir = join(
'/tmp/orca-user-data',
'orca-terminal-attribution',
@@ -210,6 +219,10 @@ describe('registerPtyHandlers', () => {
removeListener: vi.fn()
}
}
const mainWindowIpcEvent = { sender: mainWindow.webContents }
const foreignWindowIpcEvent = {
sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
}
const savedOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR
@@ -222,8 +235,15 @@ describe('registerPtyHandlers', () => {
const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
beforeEach(() => {
// Why: most PTY spawn tests assert POSIX shell behavior; Windows-specific
// cases opt into win32 explicitly below.
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_CONFIG_DIR
@@ -316,6 +336,9 @@ describe('registerPtyHandlers', () => {
vi.useRealTimers()
unregisterSshPtyProvider('ssh-1')
setLocalPtyProvider(new LocalPtyProvider())
if (savedProcessPlatform) {
Object.defineProperty(process, 'platform', savedProcessPlatform)
}
if (savedOpenCodeConfigDir !== undefined) {
process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir
} else {
@@ -3180,7 +3203,9 @@ describe('registerPtyHandlers', () => {
return call[1] as (event: unknown, args: unknown) => void
}
expect(() => listenerFor('pty:write')(null, { id: 'remote-pty', data: 'x' })).not.toThrow()
expect(() =>
listenerFor('pty:write')(mainWindowIpcEvent, { id: 'remote-pty', data: 'x' })
).not.toThrow()
expect(() =>
listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 })
).not.toThrow()
@@ -3864,7 +3889,7 @@ describe('registerPtyHandlers', () => {
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(remoteShutdown).not.toHaveBeenCalled()
getPtyWriteListener()(null, {
getPtyWriteListener()(mainWindowIpcEvent, {
id: 'ssh:ssh-reattach-fail@@relay-pty',
data: 'echo should-not-route'
})
@@ -3965,7 +3990,7 @@ describe('registerPtyHandlers', () => {
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(null, { id: appPtyId, data: 'echo nope' })
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo nope' })
expect(remoteWrite).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(appPtyId)
@@ -4958,7 +4983,7 @@ describe('registerPtyHandlers', () => {
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -4992,7 +5017,7 @@ describe('registerPtyHandlers', () => {
})
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: 'missing-pty',
data: 'a'
})
@@ -5017,7 +5042,7 @@ describe('registerPtyHandlers', () => {
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -5051,7 +5076,7 @@ describe('registerPtyHandlers', () => {
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -5088,7 +5113,7 @@ describe('registerPtyHandlers', () => {
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -5126,7 +5151,7 @@ describe('registerPtyHandlers', () => {
mockProc.emitData(pendingOutput)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -5373,7 +5398,7 @@ describe('registerPtyHandlers', () => {
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
expect(vi.getTimerCount()).toBe(0)
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
@@ -5388,7 +5413,7 @@ describe('registerPtyHandlers', () => {
const reservePrefix = '\x1b[20;2H'
const reserveChunk = `${reservePrefix}${'r'.repeat(16 * 1024 - reservePrefix.length)}`
for (let index = 0; index < 16; index++) {
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
@@ -5396,7 +5421,7 @@ describe('registerPtyHandlers', () => {
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529)
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
@@ -5561,7 +5586,7 @@ describe('registerPtyHandlers', () => {
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(null, {
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
@@ -5674,10 +5699,15 @@ describe('registerPtyHandlers', () => {
rows: 24
})) as { id: string }
expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true)
expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: '\x03'
})
).toBe(true)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x03')
expect(
handlers.get('pty:writeAccepted')!(null, {
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: 'missing-pty-for-write-ack',
data: '\x03'
})
@@ -5685,6 +5715,95 @@ describe('registerPtyHandlers', () => {
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
})
it('rejects malformed and cross-window pty write IPC before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const write = getPtyWriteListener() as (event: unknown, args: unknown) => void
const writeAccepted = handlers.get('pty:writeAccepted')! as (
event: unknown,
args: unknown
) => unknown
write(mainWindowIpcEvent, null)
write(mainWindowIpcEvent, { id: '', data: 'x' })
write(mainWindowIpcEvent, { id: result.id, data: 1 })
write(foreignWindowIpcEvent, { id: result.id, data: 'x' })
expect(writeAccepted(mainWindowIpcEvent, null)).toBe(false)
expect(writeAccepted(mainWindowIpcEvent, { id: '', data: 'x' })).toBe(false)
expect(writeAccepted(mainWindowIpcEvent, { id: result.id, data: 1 })).toBe(false)
expect(writeAccepted(foreignWindowIpcEvent, { id: result.id, data: 'x' })).toBe(false)
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('chunks large acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('')
await expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id: result.id, data: text })
).resolves.toBe(true)
expect(mockProc.proc.write).toHaveBeenNthCalledWith(
1,
'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
)
expect(mockProc.proc.write).toHaveBeenNthCalledWith(2, 'tail')
})
it('yields while validating accepted large acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
vi.useFakeTimers()
const writeResult = handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: text
})
expect(writeResult).toBeInstanceOf(Promise)
expect(mockProc.proc.write).not.toHaveBeenCalled()
await vi.runAllTimersAsync()
await expect(writeResult).resolves.toBe(true)
expect(mockProc.proc.write.mock.calls.map(([chunk]) => chunk).join('')).toBe(text)
})
it('rejects oversized acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)
})
).toBe(false)
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('seeds headless terminal state with cold-restore cwd metadata', async () => {
const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }]
const coldRestore = {
+102 -9
View File
@@ -5,7 +5,14 @@ boundary. Splitting it by line count would scatter tightly coupled terminal
process behavior across files without a cleaner ownership seam. */
import { join, delimiter } from 'path'
import { randomUUID } from 'crypto'
import { type BrowserWindow, type WebContents, ipcMain, app } from 'electron'
import {
type BrowserWindow,
type IpcMainEvent,
type IpcMainInvokeEvent,
type WebContents,
ipcMain,
app
} from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
@@ -50,6 +57,10 @@ import {
launchSourceSchema,
requestKindSchema
} from '../../shared/telemetry-events'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
} from '../../shared/terminal-input'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
@@ -2656,7 +2667,85 @@ export function registerPtyHandlers(
}
)
const writePtyInput = (args: { id: string; data: string }): boolean => {
const writePtyProviderInputWithinLimit = (
provider: IPtyProvider,
id: string,
data: string
): boolean | Promise<boolean> => {
const chunks = iterateTerminalInputChunks(data)
const first = chunks.next()
if (first.done) {
provider.write(id, data)
return true
}
const second = chunks.next()
if (second.done) {
provider.write(id, first.value)
return true
}
return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value)
}
const writePtyProviderInput = (
provider: IPtyProvider,
id: string,
data: string
): boolean | Promise<boolean> => {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (typeof tooLarge === 'boolean') {
return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data)
}
return tooLarge
.then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data)))
.catch(() => false)
} catch {
return false
}
}
const writePtyProviderInputChunks = async (
provider: IPtyProvider,
id: string,
chunks: Iterator<string>,
firstChunk: string,
secondChunk: string
): Promise<boolean> => {
try {
let chunk: IteratorResult<string> = { done: false, value: firstChunk }
let nextChunk: IteratorResult<string> = { done: false, value: secondChunk }
while (!chunk.done) {
provider.write(id, chunk.value)
if (!nextChunk.done) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
chunk = nextChunk
nextChunk = chunks.next()
}
return true
} catch {
return false
}
}
type PtyWritePayload = { id: string; data: string }
const isPtyWritePayload = (value: unknown): value is PtyWritePayload =>
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
(value as { id: string }).id.length > 0 &&
typeof (value as { data?: unknown }).data === 'string'
const isPtyWriteEventFromMainWindow = (
event: IpcMainEvent | IpcMainInvokeEvent,
mainWebContents: WebContents
): boolean =>
event.sender === mainWebContents &&
!mainWindow.isDestroyed() &&
!(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed())
const writePtyInput = (args: PtyWritePayload): boolean | Promise<boolean> => {
// Why: defense-in-depth for the mobile-presence lock. The renderer's
// xterm.onData guard already drops desktop keystrokes when mobile is
// driving, but a stale view between the main-side state flip and the
@@ -2674,14 +2763,13 @@ export function registerPtyHandlers(
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
provider.write(args.id, args.data)
return true
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
}
}
const writePtyInputAccepted = (args: { id: string; data: string }): boolean => {
const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise<boolean> => {
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
@@ -2700,17 +2788,22 @@ export function registerPtyHandlers(
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
provider.write(args.id, args.data)
return true
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
}
}
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
ipcMain.on('pty:write', (event, args: unknown) => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
return
}
writePtyInput(args)
})
ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => {
ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
return false
}
return writePtyInputAccepted(args)
})
+14 -2
View File
@@ -24,6 +24,7 @@ const {
registerPetHandlersMock,
registerSessionHandlersMock,
registerUIHandlersMock,
setTrustedUIRendererWebContentsIdMock,
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
registerRuntimeEnvironmentHandlersMock,
@@ -33,6 +34,7 @@ const {
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
registerRateLimitHandlersMock,
registerBrowserHandlersMock,
@@ -73,6 +75,7 @@ const {
registerPetHandlersMock: vi.fn(),
registerSessionHandlersMock: vi.fn(),
registerUIHandlersMock: vi.fn(),
setTrustedUIRendererWebContentsIdMock: vi.fn(),
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
registerRuntimeEnvironmentHandlersMock: vi.fn(),
@@ -82,6 +85,7 @@ const {
registerAgentTrustHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
registerRateLimitHandlersMock: vi.fn(),
registerBrowserHandlersMock: vi.fn(),
@@ -207,7 +211,8 @@ vi.mock('./session', () => ({
}))
vi.mock('./ui', () => ({
registerUIHandlers: registerUIHandlersMock
registerUIHandlers: registerUIHandlersMock,
setTrustedUIRendererWebContentsId: setTrustedUIRendererWebContentsIdMock
}))
vi.mock('./emulator-frame-stream', () => ({
@@ -259,7 +264,8 @@ vi.mock('../window/attach-main-window-services', () => ({
}))
vi.mock('../window/clipboard-ipc-handlers', () => ({
registerClipboardHandlers: registerClipboardHandlersMock
registerClipboardHandlers: registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsId: setTrustedClipboardRendererWebContentsIdMock
}))
vi.mock('./browser', () => ({
@@ -313,6 +319,7 @@ describe('registerCoreHandlers', () => {
registerPetHandlersMock.mockReset()
registerSessionHandlersMock.mockReset()
registerUIHandlersMock.mockReset()
setTrustedUIRendererWebContentsIdMock.mockReset()
registerFilesystemHandlersMock.mockReset()
registerRuntimeHandlersMock.mockReset()
registerRuntimeEnvironmentHandlersMock.mockReset()
@@ -322,6 +329,7 @@ describe('registerCoreHandlers', () => {
registerAgentTrustHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
registerRateLimitHandlersMock.mockReset()
registerBrowserHandlersMock.mockReset()
@@ -415,6 +423,8 @@ describe('registerCoreHandlers', () => {
expect(registerClipboardHandlersMock).toHaveBeenCalled()
expect(registerUpdaterHandlersMock).toHaveBeenCalled()
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null)
expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(null)
expect(setTrustedUIRendererWebContentsIdMock).toHaveBeenCalledWith(null)
expect(registerBrowserHandlersMock).toHaveBeenCalled()
expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled()
expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store)
@@ -448,6 +458,8 @@ describe('registerCoreHandlers', () => {
// Web contents ID should always be updated
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(42)
expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(42)
expect(setTrustedUIRendererWebContentsIdMock).toHaveBeenCalledWith(42)
// IPC handlers should NOT be registered again
expect(registerCliHandlersMock).not.toHaveBeenCalled()
expect(registerPreflightHandlersMock).not.toHaveBeenCalled()
+7 -2
View File
@@ -42,7 +42,7 @@ import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
import { registerShellHandlers } from './shell'
import { registerPetHandlers } from './pet'
import { registerUIHandlers } from './ui'
import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui'
import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream'
import { registerSpeechHandlers } from './speech'
import { registerCodexAccountHandlers } from './codex-accounts'
@@ -50,7 +50,10 @@ import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
import { registerClaudeAccountHandlers } from './claude-accounts'
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers'
import {
registerClipboardHandlers,
setTrustedClipboardRendererWebContentsId
} from '../window/clipboard-ipc-handlers'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { OpenCodeUsageStore } from '../opencode-usage/store'
@@ -92,6 +95,8 @@ export function registerCoreHandlers(
// if a channel is registered twice, so we guard to register only once and
// just update the per-window web-contents ID on subsequent calls.
setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId)
setTrustedClipboardRendererWebContentsId(mainWindowWebContentsId)
setTrustedUIRendererWebContentsId(mainWindowWebContentsId)
setAgentBrowserBridgeRef(runtime.getAgentBrowserBridge())
if (registered) {
return
+49
View File
@@ -431,6 +431,55 @@ describe('SSH IPC handlers', () => {
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
})
it('ssh:connect exposes the detected remote platform in public state', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Windows Server',
host: 'windows.example.com',
port: 22,
username: 'deploy'
}
const hostPlatform = {
relayPlatform: 'win32-x64',
os: 'win32',
arch: 'x64',
pathFlavor: 'windows',
commandDialect: 'powershell',
pathSeparator: '\\',
pathDelimiter: ';'
}
mockDeployAndLaunchRelay.mockResolvedValueOnce({
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
hostPlatform
})
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue({})
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'win32'
})
expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', {
targetId: 'ssh-1',
state: {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'win32'
}
})
})
it('surfaces relay channel loss while the SSH connection remains alive', async () => {
vi.useFakeTimers()
const target: SshTarget = {
+14 -5
View File
@@ -155,10 +155,18 @@ function broadcastSshState(
): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', { targetId, state })
win.webContents.send('ssh:state-changed', {
targetId,
state: withSshRemotePlatform(targetId, state)
})
}
}
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os
return remotePlatform ? { ...state, remotePlatform } : state
}
function publishRelayOverride(
getMainWindow: () => BrowserWindow | null,
targetId: string,
@@ -166,7 +174,7 @@ function publishRelayOverride(
error: string | null,
reconnectAttempt: number
): void {
const state: SshConnectionState = { targetId, status, error, reconnectAttempt }
const state = withSshRemotePlatform(targetId, { targetId, status, error, reconnectAttempt })
relayStateOverrides.set(targetId, state)
broadcastSshState(getMainWindow, targetId, state)
}
@@ -176,7 +184,8 @@ function clearRelayStateOverride(targetId: string): void {
}
function getPublicSshState(targetId: string): SshConnectionState | undefined {
return relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) ?? undefined
const state = relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId)
return state ? withSshRemotePlatform(targetId, state) : undefined
}
function broadcastPortForwards(getMainWindow: () => BrowserWindow | null, targetId: string): void {
@@ -756,12 +765,12 @@ export function registerSshHandlers(
clearRelayStateOverride(targetId)
win.webContents.send('ssh:state-changed', {
targetId,
state: {
state: withSshRemotePlatform(targetId, {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0
}
})
})
}
} catch (err) {
+190
View File
@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { fromWebContentsMock, getAllWindowsMock, handleMock, onMock, removeAllListenersMock } =
vi.hoisted(() => ({
fromWebContentsMock: vi.fn(),
getAllWindowsMock: vi.fn(() => []),
handleMock: vi.fn(),
onMock: vi.fn(),
removeAllListenersMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
fromWebContents: fromWebContentsMock,
getAllWindows: getAllWindowsMock
},
ipcMain: {
handle: handleMock,
on: onMock,
removeAllListeners: removeAllListenersMock
}
}))
import {
clearTrustedUIRendererWebContentsId,
registerUIHandlers,
setTrustedUIRendererWebContentsId
} from './ui'
function makeStore() {
return {
onUIChanged: vi.fn(),
getUI: vi.fn(() => ({})),
updateUI: vi.fn(),
recordFeatureInteraction: vi.fn()
}
}
function makeUIEvent(senderOverrides: Record<string, unknown> = {}): {
sender: Record<string, unknown>
} {
return {
sender: {
id: 17,
getType: () => 'window',
getURL: () => 'file:///orca/index.html',
isDestroyed: () => false,
...senderOverrides
}
}
}
function getNativePasteHandler():
| ((event: ReturnType<typeof makeUIEvent>, options?: { mode?: unknown }) => void)
| undefined {
return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1]
}
describe('registerUIHandlers', () => {
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', '')
fromWebContentsMock.mockReset()
getAllWindowsMock.mockReset()
getAllWindowsMock.mockReturnValue([])
handleMock.mockReset()
onMock.mockReset()
removeAllListenersMock.mockReset()
setTrustedUIRendererWebContentsId(null)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('routes native paste fallback to the requesting webContents only', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
const event = makeUIEvent()
const sender = event.sender
setTrustedUIRendererWebContentsId(17)
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
expect(removeAllListenersMock).toHaveBeenCalledWith('ui:performNativePaste')
const nativePasteHandler = getNativePasteHandler()
nativePasteHandler?.(event)
nativePasteHandler?.(event, { mode: 'paste-and-match-style' })
expect(fromWebContentsMock).toHaveBeenCalledWith(sender)
expect(paste).toHaveBeenCalledTimes(1)
expect(pasteAndMatchStyle).toHaveBeenCalledTimes(1)
})
it('ignores native paste fallback from stale or browser senders', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
setTrustedUIRendererWebContentsId(17)
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
const nativePasteHandler = getNativePasteHandler()
nativePasteHandler?.(makeUIEvent({ id: 42 }))
nativePasteHandler?.(makeUIEvent({ getType: () => 'webview' }))
expect(fromWebContentsMock).not.toHaveBeenCalled()
expect(paste).not.toHaveBeenCalled()
expect(pasteAndMatchStyle).not.toHaveBeenCalled()
})
it('ignores native paste fallback from destroyed senders', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
const nativePasteHandler = getNativePasteHandler()
nativePasteHandler?.(makeUIEvent({ isDestroyed: () => true }))
expect(fromWebContentsMock).not.toHaveBeenCalled()
expect(paste).not.toHaveBeenCalled()
expect(pasteAndMatchStyle).not.toHaveBeenCalled()
})
it('rejects packaged file-url senders until the main window id is registered', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
const event = makeUIEvent()
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
getNativePasteHandler()?.(event)
expect(fromWebContentsMock).not.toHaveBeenCalled()
expect(paste).not.toHaveBeenCalled()
expect(pasteAndMatchStyle).not.toHaveBeenCalled()
})
it('clears the trusted renderer id without clearing a newer window id', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
setTrustedUIRendererWebContentsId(17)
clearTrustedUIRendererWebContentsId(42)
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
getNativePasteHandler()?.(makeUIEvent())
expect(paste).toHaveBeenCalledTimes(1)
clearTrustedUIRendererWebContentsId(17)
fromWebContentsMock.mockClear()
paste.mockClear()
getNativePasteHandler()?.(makeUIEvent())
expect(fromWebContentsMock).not.toHaveBeenCalled()
expect(paste).not.toHaveBeenCalled()
})
it('allows native paste fallback only from the configured dev renderer origin', () => {
const paste = vi.fn()
const pasteAndMatchStyle = vi.fn()
const event = makeUIEvent({ getURL: () => 'http://localhost:5173/workspace' })
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
fromWebContentsMock.mockReturnValue({ webContents: { paste, pasteAndMatchStyle } })
registerUIHandlers(makeStore() as never)
const nativePasteHandler = getNativePasteHandler()
nativePasteHandler?.(event)
expect(fromWebContentsMock).toHaveBeenCalledWith(event.sender)
expect(paste).toHaveBeenCalledTimes(1)
fromWebContentsMock.mockClear()
paste.mockClear()
nativePasteHandler?.(makeUIEvent({ getURL: () => 'http://127.0.0.1:5173/workspace' }))
nativePasteHandler?.(makeUIEvent({ getURL: () => 'file:///orca/index.html' }))
nativePasteHandler?.(makeUIEvent({ getURL: () => 'not a url' }))
expect(fromWebContentsMock).not.toHaveBeenCalled()
expect(paste).not.toHaveBeenCalled()
expect(pasteAndMatchStyle).not.toHaveBeenCalled()
})
})
+50 -1
View File
@@ -1,8 +1,20 @@
import { BrowserWindow, ipcMain } from 'electron'
import { BrowserWindow, ipcMain, type WebContents } from 'electron'
import type { Store } from '../persistence'
import type { PersistedUIState } from '../../shared/types'
import { isFeatureInteractionId } from '../../shared/feature-interactions'
let trustedUIRendererWebContentsId: number | null = null
export function setTrustedUIRendererWebContentsId(webContentsId: number | null): void {
trustedUIRendererWebContentsId = webContentsId
}
export function clearTrustedUIRendererWebContentsId(webContentsId: number): void {
if (trustedUIRendererWebContentsId === webContentsId) {
trustedUIRendererWebContentsId = null
}
}
export function registerUIHandlers(store: Store): void {
// Why: UI view-state is shared between the desktop renderer and mobile (ui.set
// RPC). Broadcast every change so the desktop re-hydrates when mobile (or
@@ -29,4 +41,41 @@ export function registerUIHandlers(store: Store): void {
}
return store.recordFeatureInteraction(id)
})
ipcMain.removeAllListeners('ui:performNativePaste')
ipcMain.on('ui:performNativePaste', (event, options?: { mode?: unknown }) => {
if (!isTrustedUIRenderer(event.sender)) {
return
}
// Why: coordinated renderer paste falls back here only after no Orca owner
// claims the app-menu action; paste back into the requesting window only.
const webContents = BrowserWindow.fromWebContents(event.sender)?.webContents
if (options?.mode === 'paste-and-match-style') {
webContents?.pasteAndMatchStyle()
return
}
webContents?.paste()
})
}
function isTrustedUIRenderer(sender: WebContents): boolean {
if (sender.isDestroyed() || sender.getType() !== 'window') {
return false
}
if (trustedUIRendererWebContentsId != null) {
return sender.id === trustedUIRendererWebContentsId
}
const senderUrl = sender.getURL()
if (process.env.ELECTRON_RENDERER_URL) {
try {
return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin
} catch {
return false
}
}
// Why: packaged fallback must be tied to the created main window id, not any
// file:// document that can obtain this IPC channel.
return false
}
@@ -183,6 +183,35 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
expect(removeCalls(exec)).toEqual([])
})
it('checks branch config without line-array or whitespace-regex splitting', async () => {
const exec = makeExec({
branchConfig: [
`branch.contributor/fix.pushRemote\tunused`,
` branch.contributor/fix.remote ${FORK_REMOTE} `
].join('\r\n')
})
const splitSpy = vi.spyOn(String.prototype, 'split')
try {
await cleanupUnusedWorktreePushTargetRemoteWithExec(
REPO_PATH,
'repo-1::/wt/a',
forkTarget(),
storeOf({ 'repo-1::/wt/a': forkTarget() }),
exec
)
const usedUnboundedOutputSplit = splitSpy.mock.calls.some(([separator]) => {
return (
separator instanceof RegExp &&
(separator.source === '\\r?\\n' || separator.source === '\\s+')
)
})
expect(removeCalls(exec)).toEqual([])
expect(usedUnboundedOutputSplit).toBe(false)
} finally {
splitSpy.mockRestore()
}
})
it('keeps the remote when its URL no longer matches the fork (repurposed by the user)', async () => {
const exec = makeExec({ getUrl: 'git@github.com:someone-else/orca.git' })
await cleanupUnusedWorktreePushTargetRemoteWithExec(
+36 -8
View File
@@ -7,6 +7,7 @@ import type { Store } from '../persistence'
import type { GitPushTarget } from '../../shared/types'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
export type GitRemoteExec = (
args: string[],
@@ -62,19 +63,46 @@ async function hasBranchConfigUsingRemote(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
repoPath
)
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.some((line) => {
const value = line.split(/\s+/).slice(1).join(' ')
return value === target.remoteName || value === target.remoteUrl
})
// Why: git config output can be large; avoid materializing line/split arrays here.
for (const line of iterateProcessOutputLines(stdout)) {
const value = readBranchRemoteConfigValue(line)
if (value === target.remoteName || value === target.remoteUrl) {
return true
}
}
return false
} catch {
return false
}
}
function readBranchRemoteConfigValue(line: string): string | null {
let index = 0
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
if (index >= line.length) {
return null
}
const valueStart = index
let valueEnd = line.length
while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) {
valueEnd -= 1
}
return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null
}
function isBranchConfigSeparator(code: number): boolean {
return code === 32 || (code >= 9 && code <= 13)
}
// Exported for unit tests: the `execGit` seam lets tests drive the multi-fork
// cleanup matrix without touching a real repo.
export async function cleanupUnusedWorktreePushTargetRemoteWithExec(
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { sanitizeLinearErrorMessage } from './issue-context-errors'
describe('sanitizeLinearErrorMessage', () => {
it('removes stack frames without regex line splitting', () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
try {
expect(
sanitizeLinearErrorMessage('Linear request failed\r\n at request (sdk.js:10:2)')
).toBe('Linear request failed')
const usedStackSplit = splitSpy.mock.calls.some(
([separator]) => separator instanceof RegExp && separator.source === '\\r?\\n\\s+at\\s+'
)
expect(usedStackSplit).toBe(false)
} finally {
splitSpy.mockRestore()
}
})
it('redacts sensitive Linear payloads after trimming stack frames', () => {
expect(
sanitizeLinearErrorMessage(
'Request failed authorization: Bearer secret-token\n at request (sdk.js:10:2)'
)
).toBe('Request failed authorization: Bearer [REDACTED]')
})
})
+33 -2
View File
@@ -64,8 +64,7 @@ export function linearMessage(error: unknown): string {
export function sanitizeLinearErrorMessage(message: string): string {
// Why: provider text is useful in CLI errors, but raw SDK failures can embed secrets or user payloads.
return message
.split(/\r?\n\s+at\s+/)[0]
return stripLinearStackTrace(message)
.replace(
/(headers?\s*[:=]\s*)\{[^{}]*(?:authorization|token|api[-_]?key)[^{}]*\}/gi,
'$1[REDACTED]'
@@ -77,3 +76,35 @@ export function sanitizeLinearErrorMessage(message: string): string {
.replace(/((?:body|comment|description)\s*[:=]\s*)(["']).*?\2/gi, '$1[REDACTED]')
.trim()
}
function stripLinearStackTrace(message: string): string {
for (let index = 0; index < message.length; index += 1) {
const code = message.charCodeAt(index)
if (code !== 10 && code !== 13) {
continue
}
let candidateStart = index + 1
if (code === 13 && message.charCodeAt(candidateStart) === 10) {
candidateStart += 1
}
while (
candidateStart < message.length &&
isLinearStackWhitespace(message.charCodeAt(candidateStart))
) {
candidateStart += 1
}
if (
message.startsWith('at', candidateStart) &&
isLinearStackWhitespace(message.charCodeAt(candidateStart + 2))
) {
return message.slice(0, index)
}
}
return message
}
function isLinearStackWhitespace(code: number): boolean {
return code === 32 || (code >= 9 && code <= 13)
}
+19
View File
@@ -81,6 +81,25 @@ describe('parsePsOutput', () => {
expect(rows[0].cpu).toBe(0)
expect(rows[0].memory).toBe(0)
})
it('parses process rows without line-array or whitespace-regex splitting', async () => {
const { parsePsOutput } = await loadCollector()
const splitSpy = vi.spyOn(String.prototype, 'split')
const rows = parsePsOutput('10 1 0.5 256\r\n11 10 0 128')
const usedUnboundedSplit = splitSpy.mock.calls.some(
([separator]) =>
(typeof separator === 'string' && separator === '\n') ||
(separator instanceof RegExp && separator.source.includes('\\s+'))
)
splitSpy.mockRestore()
expect(rows).toEqual([
{ pid: 10, ppid: 1, cpu: 0.5, memory: 256 * 1024 },
{ pid: 11, ppid: 10, cpu: 0, memory: 128 * 1024 }
])
expect(usedUnboundedSplit).toBe(false)
})
})
describe('parseWmicOutput', () => {
+6 -8
View File
@@ -24,6 +24,10 @@ import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import os from 'node:os'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import {
getProcessOutputFields,
iterateProcessOutputLines
} from '../../shared/process-output-field-scanner'
import { app } from 'electron'
import type {
AppMemory,
@@ -199,14 +203,8 @@ async function enumerateUnix(): Promise<ProcRow[]> {
/** Exported for tests: parses `ps -eo pid=,ppid=,pcpu=,rss=` output. */
export function parsePsOutput(stdout: string): ProcRow[] {
const rows: ProcRow[] = []
const lines = stdout.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (line.length === 0) {
continue
}
// Split on runs of whitespace. We requested exactly 4 columns.
const fields = line.split(/\s+/, 4)
for (const line of iterateProcessOutputLines(stdout)) {
const fields = getProcessOutputFields(line, 4)
if (fields.length < 4) {
continue
}
+13
View File
@@ -179,6 +179,19 @@ describe('registerAppMenu', () => {
expect(paletteItem?.accelerator).toBeUndefined()
})
it('keeps Edit > Paste on the native Electron paste role in this split', () => {
const send = vi.fn()
getFocusedWindowMock.mockReturnValue({ webContents: { send } })
registerAppMenu(buildMenuOptions())
const editSubmenu = getSubmenu(getTemplate(), 'Edit')
const pasteItem = editSubmenu.find((item) => item.role === 'paste')
expect(pasteItem).toBeDefined()
expect(pasteItem?.click).toBeUndefined()
expect(send).not.toHaveBeenCalled()
})
it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => {
registerAppMenu(buildMenuOptions())
@@ -69,6 +69,20 @@ describe('local workspace port scanner parsing', () => {
])
})
it('parses Windows netstat rows without whitespace regex splitting', () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
const ports = parseNetstatListeningOutput(
'TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242'
)
const usedWhitespaceFieldSplit = splitSpy.mock.calls.some(
([separator]) => separator instanceof RegExp && separator.source.includes('\\s+')
)
splitSpy.mockRestore()
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, pid: 4242 }])
expect(usedWhitespaceFieldSplit).toBe(false)
})
it('parses Linux proc tcp listeners', () => {
const ports = parseProcNetTcp(
[
@@ -79,6 +93,23 @@ describe('local workspace port scanner parsing', () => {
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }])
})
it('parses Linux proc rows without whitespace regex splitting', () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
const ports = parseProcNetTcp(
[
' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode',
' 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345'
].join('\n')
)
const usedWhitespaceFieldSplit = splitSpy.mock.calls.some(
([separator]) => separator instanceof RegExp && separator.source.includes('\\s+')
)
splitSpy.mockRestore()
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }])
expect(usedWhitespaceFieldSplit).toBe(false)
})
})
describe('attributePortToWorkspace', () => {
@@ -96,10 +127,8 @@ describe('attributePortToWorkspace', () => {
})
it('falls back to command-line path evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature/node_modules/vite/bin/vite.js' },
worktrees
)
const commandPath = path.posix.resolve('/repo/worktrees/feature/node_modules/vite/bin/vite.js')
const owner = attributePortToWorkspace({ commandLine: `node ${commandPath}` }, worktrees)
expect(owner).toMatchObject({
worktreeId: 'repo::/repo/worktrees/feature',
@@ -109,7 +138,7 @@ describe('attributePortToWorkspace', () => {
it('requires command-line path boundary evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature-other/server.js' },
{ commandLine: `node ${path.posix.resolve('/repo/worktrees/feature-other/server.js')}` },
[worktrees[1]]
)
@@ -9,6 +9,7 @@ import type {
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import { getProcessOutputFields } from '../../shared/process-output-field-scanner'
import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher'
import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff'
@@ -157,11 +158,10 @@ export function parseLsofListeningOutput(output: string): RawListeningPort[] {
export function parseNetstatListeningOutput(output: string): RawListeningPort[] {
const ports: RawListeningPort[] = []
for (const line of output.split('\n')) {
const trimmed = line.trim()
if (!trimmed.toUpperCase().startsWith('TCP')) {
const fields = getProcessOutputFields(line, 6)
if (fields[0]?.toUpperCase() !== 'TCP') {
continue
}
const fields = trimmed.split(/\s+/)
const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING')
if (stateIndex < 2) {
continue
@@ -180,7 +180,7 @@ export function parseProcNetTcp(content: string): { host: string; port: number;
const results: { host: string; port: number; inode: number }[] = []
const lines = content.split('\n')
for (let i = 1; i < lines.length; i++) {
const fields = lines[i].trim().split(/\s+/)
const fields = getProcessOutputFields(lines[i], 10)
if (fields.length < 10 || fields[3] !== '0A') {
continue
}
+3 -2
View File
@@ -66,6 +66,7 @@ describe('PTY provider dispatch', () => {
isDestroyed: () => false,
webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
}
const mainWindowIpcEvent = { sender: mainWindow.webContents }
function setup(): void {
handlers.clear()
@@ -185,8 +186,8 @@ describe('PTY provider dispatch', () => {
try {
const write = handlers.get('pty:write') as (event: unknown, args: unknown) => void
write(null, { id: 'ssh:conn-a@@pty-1', data: 'a' })
write(null, { id: 'ssh:conn-b@@pty-1', data: 'b' })
write(mainWindowIpcEvent, { id: 'ssh:conn-a@@pty-1', data: 'a' })
write(mainWindowIpcEvent, { id: 'ssh:conn-b@@pty-1', data: 'b' })
expect(providerA.write).toHaveBeenCalledWith('ssh:conn-a@@pty-1', 'a')
expect(providerB.write).toHaveBeenCalledWith('ssh:conn-b@@pty-1', 'b')
+165 -2
View File
@@ -17,6 +17,7 @@ import type {
WorkspaceSessionState
} from '../../shared/types'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
import { MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection'
import {
addWorktree,
assertWorktreeCleanForRemoval,
@@ -45,6 +46,12 @@ import {
type RuntimeTerminalAgentStatusEvent
} from './orca-runtime'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import {
TERMINAL_INPUT_CHUNK_MAX_BYTES,
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR
} from '../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@@ -6213,6 +6220,49 @@ describe('OrcaRuntimeService', () => {
})
})
it('resolves Antigravity ready prompts with newline-heavy pasted tails without splitting', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
let pastedTail = ''
for (let index = 0; index < 90; index += 1) {
pastedTail += `${'pasted text '.repeat(25)}${index}\n`
}
const splitSpy = vi.spyOn(String.prototype, 'split')
runtime.onPtyData(
'pty-bg',
[
'Antigravity CLI 1.0.3\n',
'user@example.com (Antigravity Business)\n',
pastedTail,
'Gemini 4 Experimental (High)\n',
'~/orca/workspaces/orca/agy-dispatch-issue\n',
'>'
].join(''),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 })
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: true,
status: 'running'
})
const splitReadyTail = splitSpy.mock.contexts.some((context) => {
const value = typeof context === 'string' ? context : String(context)
return value.includes('antigravity cli') && value.includes('pasted text pasted text')
})
expect(splitReadyTail).toBe(false)
})
it('resolves tui-idle from an Antigravity prompt before the model line', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
@@ -6634,6 +6684,87 @@ describe('OrcaRuntimeService', () => {
expect(writes).toEqual(['continue', '\r'])
})
it('chunks large terminal.send text before provider writes', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('')
const result = await runtime.sendTerminal(handle, { text })
expect(result).toMatchObject({
handle,
accepted: true,
bytesWritten: Buffer.byteLength(text, 'utf8')
})
expect(writes).toEqual(['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'])
})
it('yields while validating accepted large terminal.send text before provider writes', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
vi.useFakeTimers()
try {
const sendPromise = runtime.sendTerminal(handle, { text })
expect(writes).toEqual([])
await vi.runAllTimersAsync()
const result = await sendPromise
expect(result).toMatchObject({
handle,
accepted: true,
bytesWritten: Buffer.byteLength(text, 'utf8')
})
expect(writes.length).toBeGreaterThan(1)
expect(writes.join('')).toBe(text)
} finally {
vi.useRealTimers()
}
})
it('rejects oversized terminal.send text before provider writes', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
await expect(
runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) })
).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR)
expect(writes).toEqual([])
})
it('reveals a background terminal session when focusing its handle', async () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' })
const runtime = new OrcaRuntimeService(store)
@@ -7367,7 +7498,7 @@ describe('OrcaRuntimeService', () => {
ptysById: Map<string, { lastOscTitle: string | null }>
}
).ptysById.get('pty-1')
expect(pty?.lastOscTitle).toBe('x'.repeat(4092))
expect(pty?.lastOscTitle).toBe('x'.repeat(MAX_OSC_TITLE_CHARS))
})
it('does not retain split ST-terminated string controls as preview text', async () => {
@@ -7465,6 +7596,37 @@ describe('OrcaRuntimeService', () => {
expect(retained).not.toContain('49m')
})
it('normalizes large CRLF-heavy terminal chunks without regex replacement or line splits', async () => {
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const splitSpy = vi.spyOn(String.prototype, 'split')
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', `${'line\r\n'.repeat(10_000)}tail`, 100)
const read = await runtime.readTerminal(terminal.handle, { limit: 5 })
const usedCrlfReplace = replaceSpy.mock.calls.some(
([pattern], index) =>
pattern instanceof RegExp &&
pattern.source === '\\r\\n' &&
typeof replaceSpy.mock.contexts[index] === 'string' &&
replaceSpy.mock.contexts[index].length > 10_000
)
const usedLineSplit = splitSpy.mock.calls.some(([separator], index) => {
const splitSeparator = separator as unknown
return (
(splitSeparator === '\n' ||
(splitSeparator instanceof RegExp && splitSeparator.source === '\\r?\\n')) &&
typeof splitSpy.mock.contexts[index] === 'string' &&
splitSpy.mock.contexts[index].length > 10_000
)
})
expect(read.tail.at(-1)).toBe('tail')
expect(usedCrlfReplace).toBe(false)
expect(usedLineSplit).toBe(false)
})
it('bounds retained partial terminal output before preview reads', async () => {
const runtime = new OrcaRuntimeService(store)
@@ -18487,7 +18649,8 @@ describe('OrcaRuntimeService', () => {
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID)
} finally {
await rm(parentDir, { recursive: true, force: true })
// Why: Windows can keep a just-inspected git admin dir busy briefly.
await rm(parentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
}
})
+130 -38
View File
@@ -21,6 +21,11 @@ import {
createAgentStatusOscProcessor,
type ProcessedAgentStatusChunk
} from '../../shared/agent-status-osc'
import {
isTerminalInputTooLargeWithYield,
TERMINAL_INPUT_TOO_LARGE_ERROR,
iterateTerminalInputChunks
} from '../../shared/terminal-input'
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
import {
cleanupClaimedCloneTarget,
@@ -7888,6 +7893,7 @@ export class OrcaRuntimeService {
if (payload === null) {
throw new Error('invalid_terminal_send')
}
await assertTerminalInputWithinLimitWithYield(action.text)
await this.writeTerminalAction(pty.pty.ptyId, action, payload)
return {
handle,
@@ -7904,6 +7910,7 @@ export class OrcaRuntimeService {
if (payload === null) {
throw new Error('invalid_terminal_send')
}
await assertTerminalInputWithinLimitWithYield(action.text)
await this.writeTerminalAction(leaf.ptyId, action, payload)
@@ -7919,24 +7926,27 @@ export class OrcaRuntimeService {
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string
): Promise<void> {
// Why: TUI apps (Claude Code, etc.) treat a single large write as a paste
// event. Keep Enter/interrupt as a second write for both visible and
// background PTYs so CLI automation behaves the same either way.
// Why: direct terminal.send can carry paste-sized text from RPC/mobile
// clients; chunk text before PTY/ConPTY while preserving suffix separation.
const hasText = typeof action.text === 'string' && action.text.length > 0
const hasSuffix = action.enter || action.interrupt
if (hasText && hasSuffix) {
const textWrote = this.ptyController?.write(ptyId, action.text!) ?? false
if (!textWrote) {
throw new Error('terminal_not_writable')
}
if (hasText) {
await this.writeTerminalInputChunks(ptyId, action.text!)
}
if (hasSuffix) {
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
await new Promise((resolve) => setTimeout(resolve, 500))
if (hasText) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false
if (!suffixWrote) {
throw new Error('terminal_not_writable')
}
return
}
if (hasText) {
return
}
const wrote = this.ptyController?.write(ptyId, payload) ?? false
if (!wrote) {
@@ -7944,6 +7954,21 @@ export class OrcaRuntimeService {
}
}
private async writeTerminalInputChunks(ptyId: string, text: string): Promise<void> {
const chunks = iterateTerminalInputChunks(text)
let chunk = chunks.next()
while (!chunk.done) {
const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
chunk = chunks.next()
if (!chunk.done) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
}
async waitForTerminal(
handle: string,
options?: {
@@ -20649,16 +20674,24 @@ export function appendNormalizedToTailBuffer(
// Why: status UIs redraw a single line with CR/backspace/ANSI erase
// controls. Terminal previews are text, not a full screen model, so retain
// the latest visible redraw segment instead of appending every spinner frame.
const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`
.split('\n')
.map(applyTerminalLineControls)
const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '')
const completedLines: string[] = []
const combined = `${boundedPreviousPartialLine}${normalizedChunk}`
let lineStart = 0
for (let index = 0; index < combined.length; index += 1) {
if (combined.charCodeAt(index) !== 0x0a) {
continue
}
completedLines.push(
trimTerminalLineRight(applyTerminalLineControls(combined.slice(lineStart, index)))
)
lineStart = index + 1
}
const nextPartialLine = trimTerminalLineRight(
applyTerminalLineControls(combined.slice(lineStart))
)
const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
const newCompleteLines = pieces.length
let nextLines =
newCompleteLines > 0
? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
: previousLines
const newCompleteLines = completedLines.length
let nextLines = newCompleteLines > 0 ? [...previousLines, ...completedLines] : previousLines
let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS
if (nextLines.length > MAX_TAIL_LINES) {
@@ -20691,6 +20724,18 @@ export function appendNormalizedToTailBuffer(
}
}
function trimTerminalLineRight(line: string): string {
let end = line.length
while (end > 0) {
const code = line.charCodeAt(end - 1)
if (code !== 0x20 && code !== 0x09) {
break
}
end -= 1
}
return end === line.length ? line : line.slice(0, end)
}
function applyTerminalLineControls(line: string): string {
const carriageIndex = line.lastIndexOf('\r')
const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line
@@ -21019,6 +21064,15 @@ function buildSendPayload(action: {
return payload.length > 0 ? payload : null
}
async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise<void> {
if (!text) {
return
}
if (await isTerminalInputTooLargeWithYield(text)) {
throw new Error(TERMINAL_INPUT_TOO_LARGE_ERROR)
}
}
// Why: tui-idle relies on recognized agent CLIs setting OSC titles. If the
// terminal runs an unsupported CLI (or a plain shell), no title transition
// will ever fire. A 5-minute ceiling prevents indefinite hangs while still
@@ -21111,29 +21165,47 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null {
if (headerIndex === -1) {
return null
}
const readySegment = normalized.slice(headerIndex)
const lines = readySegment.split('\n')
let offset = 0
let lineStart = headerIndex
let modelIndex: number | null = null
let promptIndex: number | null = null
for (const line of lines) {
const trimmed = line.trim()
const lineIndex = headerIndex + offset
if (lineIndex > headerIndex && trimmed.length > 0) {
if (modelIndex === null && trimmed.startsWith('gemini')) {
modelIndex = lineIndex + line.indexOf(trimmed)
// Why: ready previews can include echoed pasted output after the header;
// scan line bounds directly instead of splitting the whole terminal tail.
for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) {
if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) {
continue
}
let trimmedStart = lineStart
let trimmedEnd = cursor
while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) {
trimmedStart += 1
}
while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) {
trimmedEnd -= 1
}
if (lineStart > headerIndex && trimmedStart < trimmedEnd) {
if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) {
modelIndex = trimmedStart
}
if (promptIndex === null && trimmed === '>') {
promptIndex = lineIndex + line.indexOf('>')
if (
promptIndex === null &&
trimmedEnd - trimmedStart === 1 &&
normalized.charCodeAt(trimmedStart) === 62
) {
promptIndex = trimmedStart
}
}
offset += line.length + 1
lineStart = cursor + 1
}
return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null
}
function isTerminalWaitWhitespace(value: string, index: number): boolean {
const code = value.charCodeAt(index)
return code === 32 || (code >= 9 && code <= 13)
}
function findTerminalWaitBlockedSignal(
normalized: string
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
@@ -21426,36 +21498,56 @@ function normalizeTerminalChunk(
return { text: chunk, pendingAnsi: '' }
}
const combined = `${pendingAnsi}${chunk}`
let text = ''
const parts: string[] = []
let textStart = 0
for (let index = 0; index < combined.length; index += 1) {
const char = combined[index]
if (char === '\x1b') {
appendTerminalNormalizedSpan(parts, combined, textStart, index)
if (index + 1 >= combined.length) {
return { text, pendingAnsi: combined.slice(index) }
return { text: parts.join(''), pendingAnsi: combined.slice(index) }
}
const parsed = parseAnsiControlSequence(combined, index)
if (!parsed) {
return {
text,
text: parts.join(''),
pendingAnsi: trimPendingAnsiControl(combined.slice(index))
}
}
index = parsed.endIndex
textStart = index + 1
continue
}
if (char === '\r' && combined[index + 1] === '\n') {
text += '\n'
appendTerminalNormalizedSpan(parts, combined, textStart, index)
parts.push('\n')
index += 1
textStart = index + 1
continue
}
const code = combined.charCodeAt(index)
if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) {
text += char
} else if (isTerminalPreviewPrintableCodeUnit(code)) {
text += char
appendTerminalNormalizedSpan(parts, combined, textStart, index)
parts.push(char)
textStart = index + 1
} else if (!isTerminalPreviewPrintableCodeUnit(code)) {
appendTerminalNormalizedSpan(parts, combined, textStart, index)
textStart = index + 1
}
}
return { text, pendingAnsi: '' }
appendTerminalNormalizedSpan(parts, combined, textStart, combined.length)
return { text: parts.join(''), pendingAnsi: '' }
}
function appendTerminalNormalizedSpan(
parts: string[],
value: string,
start: number,
end: number
): void {
if (end > start) {
parts.push(value.slice(start, end))
}
}
function isTerminalPreviewPrintableCodeUnit(code: number): boolean {
@@ -129,11 +129,16 @@ describe('remote runtime request connection integration', () => {
},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
showRepo: (selector: string) => {
if (selector !== repo.id && selector !== `id:${repo.id}`) {
throw new Error('repo_not_found')
}
return repo
},
onClientEvent: (listener: (event: RuntimeClientEvent) => void) => {
clientEventListeners.add(listener)
return () => clientEventListeners.delete(listener)
},
showRepo: () => repo,
listDetectedManagedWorktrees: () => ({
repoId: repo.id,
authoritative: true,
@@ -348,7 +353,12 @@ describe('remote runtime request connection integration', () => {
},
watchFileExplorer: async () => () => {},
listRepos: () => [repo],
showRepo: () => repo,
showRepo: (selector: string) => {
if (selector !== repo.id && selector !== `id:${repo.id}`) {
throw new Error('repo_not_found')
}
return repo
},
listDetectedManagedWorktrees: () => ({
repoId: repo.id,
authoritative: true,
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, type RpcRequest } from './core'
import { defineMethod, InvalidArgumentError, type RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
function makeRequest(method: string, params?: unknown): RpcRequest {
@@ -24,6 +24,19 @@ const METHODS = [
name: 'browser.click',
params: z.object({ page: z.string().min(1, 'Missing page') }),
handler: () => ({ ok: true })
}),
defineMethod({
name: 'orchestration.throwZod',
params: z.object({}),
handler: () =>
z.object({ title: z.string().min(1, 'Handler title missing') }).parse({ title: '' })
}),
defineMethod({
name: 'orchestration.invalidArgument',
params: z.object({}),
handler: () => {
throw new InvalidArgumentError('Async validation rejected payload')
}
})
]
@@ -81,4 +94,32 @@ describe('RpcDispatcher computer-use validation errors', () => {
})
expect(response.ok === false ? response.error : null).not.toHaveProperty('data')
})
it('preserves formatted Zod issue messages thrown by method handlers', async () => {
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
const response = await dispatcher.dispatch(makeRequest('orchestration.throwZod', {}))
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Handler title missing'
}
})
})
it('maps async validation errors to invalid_argument without shadowing Zod formatting', async () => {
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
const response = await dispatcher.dispatch(makeRequest('orchestration.invalidArgument', {}))
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Async validation rejected payload'
}
})
})
})
+8 -3
View File
@@ -4,6 +4,7 @@
// runtime-rpc.ts focused on framing/auth/connection bookkeeping.
import {
ZodError,
InvalidArgumentError,
buildRegistry,
formatZodError,
isStreamingMethod,
@@ -192,6 +193,13 @@ export class RpcDispatcher {
}
private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse {
if (error instanceof ZodError) {
return this.invalidArgumentResponse(request, meta, formatZodError(error))
}
if (error instanceof InvalidArgumentError) {
return this.invalidArgumentResponse(request, meta, error.message)
}
// Why: browser methods throw BrowserError with a structured `code`;
// every other runtime error has a plain-message code. Routing by method
// prefix keeps the mapping a single decision rather than a per-method
@@ -202,9 +210,6 @@ export class RpcDispatcher {
if (request.method.startsWith('emulator.')) {
return mapEmulatorError(request.id, meta, error)
}
if (error instanceof ZodError) {
return this.invalidArgumentResponse(request, meta, formatZodError(error))
}
return mapRuntimeError(request.id, meta, error)
}
+2 -18
View File
@@ -6,14 +6,12 @@ import {
Element,
Eval,
Exec,
Fill,
Find,
FullScreenshot,
Get,
Goto,
Highlight,
Is,
KeyboardInsert,
Keypress,
LimitParam,
ProfileCreate,
@@ -31,10 +29,10 @@ import {
TabProfileClone,
TabShow,
TabSwitch,
Type,
Upload,
Wait
} from './browser-schemas'
import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods'
export const BROWSER_CORE_METHODS: RpcMethod[] = [
defineMethod({
@@ -52,16 +50,7 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
params: Goto,
handler: async (params, { runtime }) => runtime.browserGoto(params)
}),
defineMethod({
name: 'browser.fill',
params: Fill,
handler: async (params, { runtime }) => runtime.browserFill(params)
}),
defineMethod({
name: 'browser.type',
params: Type,
handler: async (params, { runtime }) => runtime.browserType(params)
}),
...BROWSER_TEXT_METHODS,
defineMethod({
name: 'browser.select',
params: Select,
@@ -247,11 +236,6 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
params: Is,
handler: async (params, { runtime }) => runtime.browserIs(params)
}),
defineMethod({
name: 'browser.keyboardInsertText',
params: KeyboardInsert,
handler: async (params, { runtime }) => runtime.browserKeyboardInsertText(params)
}),
defineMethod({
name: 'browser.find',
params: Find,
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation'
import { BrowserTarget, OptionalFiniteNumber } from '../schemas'
import {
ClipboardWrite,
@@ -132,7 +133,10 @@ export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
defineMethod({
name: 'browser.clipboardWrite',
params: ClipboardWrite,
handler: async (params, { runtime }) => runtime.browserClipboardWrite(params)
handler: async (params, { runtime }) => {
await assertRpcClipboardTextWriteWithinLimit(params.text)
return runtime.browserClipboardWrite(params)
}
}),
defineMethod({
name: 'browser.dialogAccept',
@@ -9,6 +9,7 @@ import {
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
requiredStringAllowingEmpty,
requiredString
} from '../schemas'
@@ -22,9 +23,7 @@ export const Goto = BrowserTarget.extend({
export const Fill = BrowserTarget.extend({
element: requiredString('Missing required --element'),
value: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing required --value'
})
value: requiredStringAllowingEmpty('Missing required --value')
})
export const Type = BrowserTarget.extend({
@@ -0,0 +1,30 @@
import { defineMethod, type RpcMethod } from '../core'
import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation'
import { Fill, KeyboardInsert, Type } from './browser-schemas'
export const BROWSER_TEXT_METHODS: RpcMethod[] = [
defineMethod({
name: 'browser.fill',
params: Fill,
handler: async (params, { runtime }) => {
await assertRpcClipboardTextWriteWithinLimit(params.value)
return runtime.browserFill(params)
}
}),
defineMethod({
name: 'browser.type',
params: Type,
handler: async (params, { runtime }) => {
await assertRpcClipboardTextWriteWithinLimit(params.input)
return runtime.browserType(params)
}
}),
defineMethod({
name: 'browser.keyboardInsertText',
params: KeyboardInsert,
handler: async (params, { runtime }) => {
await assertRpcClipboardTextWriteWithinLimit(params.text)
return runtime.browserKeyboardInsertText(params)
}
})
]
@@ -2,9 +2,15 @@ import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS,
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
} from '../../../../shared/clipboard-text'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
import { BROWSER_SCREENCAST_METHODS } from './browser-screencast'
import { ClipboardWrite, Fill, KeyboardInsert, Type } from './browser-schemas'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
@@ -235,4 +241,115 @@ describe('browser RPC methods', () => {
})
expect(runtime.browserCheck).not.toHaveBeenCalled()
})
it('rejects oversized browser clipboard writes before runtime dispatch', async () => {
const secret = 'browser-secret-token'
const runtime = {
getRuntimeId: () => 'test-runtime',
browserClipboardWrite: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS })
const response = await dispatcher.dispatch(
makeRequest('browser.clipboardWrite', {
page: 'page-1',
text: secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
})
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
}
})
expect(JSON.stringify(response)).not.toContain(secret)
expect(runtime.browserClipboardWrite).not.toHaveBeenCalled()
})
it('leaves browser text byte limits to async handlers', () => {
const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
expect(Fill.safeParse({ element: '@e1', value: text }).success).toBe(true)
expect(Type.safeParse({ input: text }).success).toBe(true)
expect(KeyboardInsert.safeParse({ text }).success).toBe(true)
expect(ClipboardWrite.safeParse({ text }).success).toBe(true)
})
it('yields while validating large accepted browser text insertion before dispatch', async () => {
vi.useFakeTimers()
try {
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const runtime = {
getRuntimeId: () => 'test-runtime',
browserType: vi.fn().mockResolvedValue({ typed: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
const responsePromise = dispatcher.dispatch(makeRequest('browser.type', { input: text }))
await Promise.resolve()
expect(runtime.browserType).not.toHaveBeenCalled()
await vi.runOnlyPendingTimersAsync()
const response = await responsePromise
expect(response).toMatchObject({
ok: true,
result: { typed: true }
})
expect(runtime.browserType).toHaveBeenCalledWith({ input: text })
} finally {
vi.useRealTimers()
}
})
it('rejects oversized browser text insertion before runtime dispatch', async () => {
const secret = 'browser-insert-secret'
const runtime = {
getRuntimeId: () => 'test-runtime',
browserFill: vi.fn().mockResolvedValue({ filled: '@e1' }),
browserType: vi.fn().mockResolvedValue({ typed: true }),
browserKeyboardInsertText: vi.fn().mockResolvedValue({ inserted: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
const text = [secret, 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)].join('')
const fillResponse = await dispatcher.dispatch(
makeRequest('browser.fill', { element: '@e1', value: text })
)
const typeResponse = await dispatcher.dispatch(makeRequest('browser.type', { input: text }))
const keyboardInsertResponse = await dispatcher.dispatch(
makeRequest('browser.keyboardInsertText', { text })
)
expect(fillResponse).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
}
})
expect(typeResponse).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
}
})
expect(keyboardInsertResponse).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR
}
})
expect(JSON.stringify([fillResponse, typeResponse, keyboardInsertResponse])).not.toContain(
secret
)
expect(runtime.browserFill).not.toHaveBeenCalled()
expect(runtime.browserType).not.toHaveBeenCalled()
expect(runtime.browserKeyboardInsertText).not.toHaveBeenCalled()
})
})
@@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
CLIPBOARD_IMAGE_TOO_LARGE_ERROR
} from '../../../../shared/clipboard-image'
const { saveClipboardImageBufferAsTempFile } = vi.hoisted(() => ({
saveClipboardImageBufferAsTempFile: vi.fn()
@@ -73,6 +77,26 @@ describe('clipboard RPC methods', () => {
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('rejects oversized direct clipboard image payloads before base64 validation', async () => {
const base64Test = vi.spyOn(RegExp.prototype, 'test')
const dispatcher = makeDispatcher()
try {
const response = await dispatcher.dispatch(
makeRequest('clipboard.saveImageAsTempFile', {
contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_MAX_BASE64_CHARS + 1)
})
)
expect(response).toMatchObject({ ok: false })
expect(JSON.stringify(response)).toContain(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
expect(base64Test).not.toHaveBeenCalled()
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
} finally {
base64Test.mockRestore()
}
})
it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => {
saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png')
const dispatcher = makeDispatcher()
@@ -170,6 +194,35 @@ describe('clipboard RPC methods', () => {
).resolves.toMatchObject({ ok: false })
})
it('rejects oversized clipboard image upload chunks before base64 validation', async () => {
const base64Test = vi.spyOn(RegExp.prototype, 'test')
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
try {
const response = await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4)
})
)
expect(response).toMatchObject({ ok: false })
expect(JSON.stringify(response)).toContain('Clipboard image chunk is too large')
expect(base64Test).not.toHaveBeenCalled()
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
} finally {
base64Test.mockRestore()
}
})
it('rejects uploads beyond the existing total clipboard image limit', async () => {
const dispatcher = makeDispatcher()
+32 -17
View File
@@ -2,8 +2,12 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file'
import { randomUUID } from 'node:crypto'
import {
CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
CLIPBOARD_IMAGE_TOO_LARGE_ERROR
} from '../../../../shared/clipboard-image'
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8
const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000
@@ -71,14 +75,29 @@ function assertValidBase64Content(value: string): void {
}
}
function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) {
return z.unknown().transform((value, ctx): string => {
if (typeof value !== 'string') {
ctx.addIssue({ code: 'custom', message: 'Missing image content' })
return z.NEVER
}
if (value.length > maxChars) {
ctx.addIssue({ code: 'custom', message: tooLargeMessage })
return z.NEVER
}
if (!isValidBase64(value)) {
ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' })
return z.NEVER
}
return value
})
}
const SaveImageAsTempFile = z.object({
contentBase64: z
.unknown()
.refine((v): v is string => typeof v === 'string', { message: 'Missing image content' })
.refine((value) => value.length <= MAX_CLIPBOARD_IMAGE_BASE64_CHARS, {
message: 'Clipboard image is too large'
})
.refine(isValidBase64, 'Clipboard image content must be base64'),
contentBase64: clipboardImageBase64Payload(
MAX_CLIPBOARD_IMAGE_BASE64_CHARS,
CLIPBOARD_IMAGE_TOO_LARGE_ERROR
),
connectionId: z.string().min(1).nullable().optional()
})
@@ -87,21 +106,17 @@ const StartImageUpload = z.object({
.number()
.int()
.nonnegative()
.max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, 'Clipboard image is too large'),
.max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR),
connectionId: z.string().min(1).nullable().optional()
})
const AppendImageUploadChunk = z.object({
uploadId: z.string().min(1),
offset: z.number().int().nonnegative(),
contentBase64: z
.unknown()
.refine((v): v is string => typeof v === 'string', { message: 'Missing image content' })
.refine(
(value) => value.length <= CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
'Clipboard image chunk is too large'
)
.refine(isValidBase64, 'Clipboard image content must be base64')
contentBase64: clipboardImageBase64Payload(
CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
'Clipboard image chunk is too large'
)
})
const CommitImageUpload = z.object({
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text'
const computerMocks = vi.hoisted(() => ({
callComputerSidecarAction: vi.fn(),
@@ -193,6 +194,18 @@ describe('computer action RPC methods', () => {
})
})
it('leaves oversized computer paste text to async sidecar validation', async () => {
const secret = 'computer-paste-secret'
const text = secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
expect(
findMethod('computer.pasteText').params!.safeParse({
app: 'Finder',
text
}).success
).toBe(true)
})
it('dispatches scroll and setValue actions through the sidecar', async () => {
computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true })
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildRegistry } from '../core'
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text'
const computerMocks = vi.hoisted(() => ({
callComputerSidecarAction: vi.fn(),
@@ -237,6 +238,14 @@ describe('computer RPC methods', () => {
findMethod('computer.hotkey').params!.parse({ app: 'Finder', key: 'Ctrl+A+B' })
).toThrow(/Hotkey requires a modifier and one key/)
})
it('leaves pasteText byte limits to async sidecar validation', () => {
const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
expect(
findMethod('computer.pasteText').params!.safeParse({ app: 'Finder', text }).success
).toBe(true)
})
})
function findMethod(name: string) {
+132 -44
View File
@@ -1,6 +1,11 @@
/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */
import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import {
InvalidArgumentError,
defineMethod,
defineStreamingMethod,
type RpcAnyMethod
} from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import type { DriverState, OrcaRuntimeService } from '../../orca-runtime'
import {
@@ -14,6 +19,12 @@ import {
} from '../../../../shared/terminal-stream-protocol'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry'
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR,
isTerminalInputTooLargeWithYield
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
// Why: when a mobile client subscribes the server resizes the PTY to phone
// dims and serializes the buffer. Sending only the visible screen meant
@@ -31,7 +42,6 @@ const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
const terminalStreamTextEncoder = new TextEncoder()
let nextTerminalStreamId = 1
type SnapshotFrameOptions = {
@@ -89,6 +99,7 @@ type TerminalMultiplexStream = {
type TerminalOutputChunk = {
data: string
bytes: number
meta?: { seq?: number; rawLength?: number }
}
@@ -136,11 +147,15 @@ function createTerminalOutputBatcher(
return
}
chunks.push(data)
bytes += terminalStreamByteLength(data)
const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
bytes += measurement.byteLength
if (typeof meta?.seq === 'number') {
lastSeq = meta.seq
}
if (bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) {
if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) {
flush()
return
}
@@ -162,50 +177,73 @@ function createTerminalOutputBatcher(
}
}
function splitTerminalOutputFrameChunks(
function* iterateTerminalOutputFrameChunks(
data: string,
meta?: { seq?: number; rawLength?: number }
): TerminalOutputFrameChunk[] {
const bytes = encodeTerminalStreamText(data)
if (bytes.byteLength <= TERMINAL_STREAM_CHUNK_BYTES) {
return [{ bytes, seq: meta?.seq }]
): Generator<TerminalOutputFrameChunk> {
if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
const chunks: TerminalOutputFrameChunk[] = []
const rawLength = meta?.rawLength ?? data.length
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
let chunk = ''
let chunkBytes = 0
let chunkStartOffset = 0
let offset = 0
let delayedChunk: { text: string; seq?: number } | null = null
const flushChunk = (): void => {
const takeChunk = (): { text: string; seq?: number } | null => {
if (!chunk) {
return
return null
}
const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined
chunks.push({ bytes: encodeTerminalStreamText(chunk), seq: chunkSeq })
const current = { text: chunk, seq: chunkSeq }
chunk = ''
chunkBytes = 0
chunkStartOffset = offset
return current
}
for (const part of data) {
const partBytes = terminalStreamByteLength(part)
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
flushChunk()
const nextChunk = takeChunk()
if (nextChunk) {
if (shouldDelayFinalSeq) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = nextChunk
} else {
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
}
}
}
chunk += part
chunkBytes += partBytes
offset += part.length
}
flushChunk()
if (!canPreserveChunkSeq && typeof meta?.seq === 'number' && chunks.length > 0) {
const finalChunk = takeChunk()
if (shouldDelayFinalSeq) {
// Why: if a future caller reports rawLength that cannot be mapped back to
// UTF-16 offsets, only the final frame can safely carry the high-water mark.
chunks.at(-1)!.seq = meta.seq
if (finalChunk) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = finalChunk
}
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
}
return
}
if (finalChunk) {
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
}
return chunks
}
function isTerminalInputLockedForClient(
@@ -225,6 +263,17 @@ function isTerminalInputLockedForClient(
return runtime.getDriver(ptyId).kind === 'mobile'
}
async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise<void> {
if (!text) {
return
}
// Why: runtime/mobile sends can be paste-sized; validate outside Zod so
// accepted large input yields before terminal runtime dispatch.
if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) {
throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR)
}
}
function resolveMobileFloorClientId(
driver: DriverState | null,
client: TerminalViewportClient | undefined
@@ -243,15 +292,22 @@ function appendPendingMultiplexOutput(
data: string,
meta?: { seq?: number; rawLength?: number }
): void {
stream.pendingOutput.push({ data, meta })
stream.pendingOutputBytes += terminalStreamByteLength(data)
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta })
stream.pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes)
stream.pendingOutputBytes = trimmed.bytes
stream.pendingOutputOverflowed ||= trimmed.overflowed
}
function trimPendingOutputToBudget(
pendingOutput: (string | TerminalOutputChunk)[],
pendingOutput: TerminalOutputChunk[],
pendingOutputBytes: number
): { bytes: number; overflowed: boolean } {
let omittedChunkCount = 0
@@ -260,7 +316,7 @@ function trimPendingOutputToBudget(
omittedChunkCount < pendingOutput.length
) {
const chunk = pendingOutput[omittedChunkCount]
pendingOutputBytes -= terminalStreamByteLength(typeof chunk === 'string' ? chunk : chunk.data)
pendingOutputBytes -= chunk.bytes
omittedChunkCount += 1
}
if (omittedChunkCount > 0) {
@@ -269,8 +325,28 @@ function trimPendingOutputToBudget(
return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 }
}
function measureTerminalStreamByteLength(
data: string,
options: { stopAfterBytes?: number } = {}
): { byteLength: number; exceededLimit: boolean } {
return measureClipboardTextByteLength(data, options)
}
function terminalStreamByteLength(data: string): number {
return terminalStreamTextEncoder.encode(data).byteLength
return measureTerminalStreamByteLength(data).byteLength
}
function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean {
return measureTerminalStreamByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
}
function* iterateTerminalStreamTextPayloads(data: string): Generator<Uint8Array<ArrayBufferLike>> {
if (!data) {
return
}
for (const chunk of iterateTerminalOutputFrameChunks(data)) {
yield chunk.bytes
}
}
function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean {
@@ -304,12 +380,15 @@ async function serializeBudgetedRequestedSnapshot(
if (!serialized) {
return null
}
const bytes = terminalStreamByteLength(serialized.data)
if (bytes <= REQUESTED_SNAPSHOT_BYTE_BUDGET || rows === 0) {
const overByteBudget = terminalStreamByteLengthExceeds(
serialized.data,
REQUESTED_SNAPSHOT_BYTE_BUDGET
)
if (!overByteBudget || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget: rows < requestedRows || bytes > REQUESTED_SNAPSHOT_BYTE_BUDGET
truncatedByByteBudget: rows < requestedRows || overByteBudget
}
}
}
@@ -336,17 +415,15 @@ function sendSnapshotFrames(
truncatedByByteBudget: options.truncatedByByteBudget === true
})
)
const bytes = encodeTerminalStreamText(options.data)
let chunks = 0
for (let offset = 0; offset < bytes.length; offset += TERMINAL_STREAM_CHUNK_BYTES) {
let bytes = 0
for (const chunk of iterateTerminalStreamTextPayloads(options.data)) {
chunks++
sendFrame(
TerminalStreamOpcode.SnapshotChunk,
bytes.slice(offset, offset + TERMINAL_STREAM_CHUNK_BYTES)
)
bytes += chunk.byteLength
sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk)
}
sendFrame(TerminalStreamOpcode.SnapshotEnd)
return { bytes: bytes.byteLength, chunks }
return { bytes, chunks }
}
async function serializeBudgetedMobileSnapshot(
@@ -364,13 +441,15 @@ async function serializeBudgetedMobileSnapshot(
if (!serialized) {
return null
}
const bytes = terminalStreamByteLength(serialized.data)
if (bytes <= MOBILE_SNAPSHOT_BYTE_BUDGET || rows === 0) {
const overByteBudget = terminalStreamByteLengthExceeds(
serialized.data,
MOBILE_SNAPSHOT_BYTE_BUDGET
)
if (!overByteBudget || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget:
rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || bytes > MOBILE_SNAPSHOT_BYTE_BUDGET
truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget
}
}
}
@@ -747,6 +826,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.send',
params: TerminalSend,
handler: async (params, { runtime }) => {
await assertTerminalSendTextWithinLimit(params.text)
const leaf = runtime.resolveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
@@ -1217,7 +1297,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
lastResizeCols: undefined,
resizeGeneration: 0,
outputBatcher: createTerminalOutputBatcher((data, meta) => {
for (const chunk of splitTerminalOutputFrameChunks(data, meta)) {
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
}
}),
@@ -1536,7 +1616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// resize re-stream so it only fires on an actual width change.
let lastResizeCols: number | undefined
let resizeGeneration = 0
const pendingOutput: string[] = []
const pendingOutput: TerminalOutputChunk[] = []
let pendingOutputBytes = 0
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
@@ -1583,7 +1663,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
}
outputBatcher = createTerminalOutputBatcher((data) => {
for (const chunk of splitTerminalOutputFrameChunks(data)) {
for (const chunk of iterateTerminalOutputFrameChunks(data)) {
sendFrame(TerminalStreamOpcode.Output, chunk.bytes)
}
})
@@ -1644,9 +1724,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
if (buffering) {
pendingOutput.push(data)
pendingOutputBytes += terminalStreamByteLength(data)
pendingOutputBytes = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes).bytes
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
return
}
outputBatcher?.push(data)
@@ -1699,7 +1787,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
lastResizeCols = serialized?.cols ?? size?.cols
buffering = false
for (const item of pendingOutput.splice(0)) {
outputBatcher.push(item)
outputBatcher.push(item.data, item.meta)
}
pendingOutputBytes = 0
outputBatcher.flush()
@@ -0,0 +1,19 @@
import {
CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR,
assertClipboardTextWriteWithinLimitWithYield,
isClipboardTextWriteTooLargeError
} from '../../../shared/clipboard-text'
import { InvalidArgumentError } from './core'
export async function assertRpcClipboardTextWriteWithinLimit(text: string): Promise<void> {
try {
// Why: large accepted text must yield outside Zod's synchronous parse path
// while preserving the RPC invalid_argument contract for rejected payloads.
await assertClipboardTextWriteWithinLimitWithYield(text)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
throw new InvalidArgumentError(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)
}
throw error
}
}
@@ -441,6 +441,7 @@ describe('terminal multiplex RPC', () => {
binaryFrames.splice(0)
const multibyteOutput = '界'.repeat(22_000)
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
dataListenerRef.current?.(multibyteOutput, {
seq: multibyteOutput.length,
rawLength: multibyteOutput.length
@@ -457,6 +458,8 @@ describe('terminal multiplex RPC', () => {
expect(
outputFrames.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')).join('')
).toBe(multibyteOutput)
expect(encodeSpy).not.toHaveBeenCalledWith(multibyteOutput)
encodeSpy.mockRestore()
cleanups.get('terminal-multiplex:conn-multibyte-output-batch')?.()
await dispatchPromise
@@ -171,6 +171,93 @@ describe('terminal output batching', () => {
}
})
it('encodes large binary terminal output lazily before the first output frame', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: { current?: (data: string) => void } = {}
let captureOutputFrames = false
let firstOutputEncodeCount: number | undefined
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateMobileViewport: vi.fn().mockResolvedValue(false)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-1',
sendBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
if (
captureOutputFrames &&
firstOutputEncodeCount === undefined &&
frame?.opcode === TerminalStreamOpcode.Output
) {
firstOutputEncodeCount = encodeSpy.mock.calls.length
}
binaryFrames.push(bytes)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
binaryFrames.length = 0
encodeSpy.mockClear()
captureOutputFrames = true
const output = 'x'.repeat(48 * 1024 * 3 + 17)
dataListenerRef.current?.(output)
const outputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
expect(outputFrames.length).toBeGreaterThan(1)
expect(firstOutputEncodeCount).toBe(1)
expect(
outputFrames.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')).join('')
).toBe(output)
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
it('routes binary terminal input frames back to the subscribed PTY', async () => {
const handlers = new Map<
number,
+96 -1
View File
@@ -1,8 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import { TERMINAL_METHODS } from './methods/terminal'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text'
import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR
} from '../../../shared/terminal-input'
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
@@ -16,6 +21,10 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('terminal send RPC', () => {
afterEach(() => {
vi.useRealTimers()
})
it('reports whether a terminal handle is running a recognized agent', async () => {
const runtime = stubRuntime({
isTerminalRunningAgent: vi.fn().mockResolvedValue(true)
@@ -101,6 +110,92 @@ describe('terminal send RPC', () => {
expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
})
it('rejects oversized terminal send text before runtime dispatch', async () => {
const secret = 'terminal-send-secret'
const runtime = stubRuntime({
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: [secret, 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)].join('')
})
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: TERMINAL_INPUT_TOO_LARGE_ERROR
}
})
expect(JSON.stringify(response)).not.toContain(secret)
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('rejects multibyte oversized terminal send text before runtime dispatch', async () => {
const runtime = stubRuntime({
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const text = '😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1)
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text
})
)
expect(text.length).toBeLessThan(TERMINAL_INPUT_MAX_BYTES)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: TERMINAL_INPUT_TOO_LARGE_ERROR
}
})
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('yields while validating large accepted terminal send text before runtime dispatch', async () => {
vi.useFakeTimers()
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: text.length
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const responsePromise = dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text
})
)
await Promise.resolve()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
await expect(responsePromise).resolves.toMatchObject({
ok: true,
result: { send: { accepted: true } }
})
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text,
enter: false,
interrupt: false
})
})
it('routes terminal restore fit through the runtime driver state machine', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+7
View File
@@ -109,6 +109,7 @@ export class SshRelaySession {
private _onReady: ((targetId: string) => void) | null = null
private portScanner: PortScanner | null = null
private currentConnection: SshConnection | null = null
private hostPlatform: RemoteHostPlatform | null = null
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
constructor(
@@ -174,6 +175,10 @@ export class SshRelaySession {
return this.mux
}
getHostPlatform(): RemoteHostPlatform | null {
return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform
}
getPortScanner(): PortScanner | null {
return this.portScanner
}
@@ -199,6 +204,7 @@ export class SshRelaySession {
try {
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.hostPlatform = hostPlatform ?? null
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
@@ -325,6 +331,7 @@ export class SshRelaySession {
try {
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.hostPlatform = hostPlatform ?? null
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from './ssh-connection'
const execCommandMock = vi.hoisted(() => vi.fn())
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: execCommandMock
}))
const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detection')
const conn = {} as SshConnection
describe('detectRemoteHostPlatform', () => {
beforeEach(() => {
execCommandMock.mockReset()
})
it('detects POSIX hosts from uname output', async () => {
execCommandMock.mockResolvedValueOnce('Linux x86_64\n')
await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix'
})
expect(execCommandMock).toHaveBeenCalledWith(conn, 'uname -sm')
})
it('falls back to PowerShell detection for Windows remotes', async () => {
execCommandMock
.mockRejectedValueOnce(new Error('uname unavailable'))
.mockResolvedValueOnce('Windows AMD64\r\n')
await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({
relayPlatform: 'win32-x64',
os: 'win32',
arch: 'x64',
pathFlavor: 'windows'
})
expect(execCommandMock).toHaveBeenNthCalledWith(
2,
conn,
expect.stringContaining('powershell.exe'),
{ wrapCommand: false }
)
})
it('returns null when neither probe yields a supported platform', async () => {
execCommandMock.mockResolvedValueOnce('Linux').mockResolvedValueOnce('FreeBSD x86_64')
await expect(detectRemoteHostPlatform(conn)).resolves.toBeNull()
})
it('does not use whitespace regex splitting for remote platform output', async () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
execCommandMock.mockResolvedValueOnce('Darwin arm64 extra')
await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({
relayPlatform: 'darwin-arm64'
})
const usedWhitespaceFieldSplit = splitSpy.mock.calls.some(
([separator]) => separator instanceof RegExp && separator.source.includes('\\s+')
)
splitSpy.mockRestore()
expect(usedWhitespaceFieldSplit).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More