mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Fix paste ownership, input bounds, and IPC validation
Supersedes #5745, #5746, and #5747.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)}...`)
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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+/)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user