From c16ea9bae1e0e1a9cb35029a5d4afa22e7a4a382 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 11 Jul 2026 02:03:14 -0700 Subject: [PATCH 1/8] fix(crash-reporting): make Windows capture and submission reliable (#8242) * fix(crash-reporting): make Windows capture and submission reliable * fix(crash-reporting): harden recovery fallbacks * test(crash-reporting): cover returned copy failures * fix(crash-reporting): keep Windows recovery responsive * fix(crash-reporting): retry transient identity lookup --- .../crash-feedback-diagnostic-bundle.ts | 119 ++++++++++ .../crash-report-copy-text.test.ts | 41 ++++ .../crash-reporting/crash-report-copy-text.ts | 53 +++++ .../crash-report-store.test.ts | 83 ++++++- .../crash-reporting/crash-report-store.ts | 73 +++++- .../durable-crash-breadcrumb.test.ts | 74 ++++++ .../durable-crash-breadcrumb.ts | 33 +++ .../process-gone-dedupe.test.ts | 21 ++ .../crash-reporting/process-gone-dedupe.ts | 37 ++- .../process-gone-diagnostics.test.ts | 2 + .../process-gone-diagnostics.ts | 6 +- .../process-gone-recorder.test.ts | 213 ++++++++++++++++++ .../crash-reporting/process-gone-recorder.ts | 137 +++++++++++ src/main/index.ts | 93 +------- src/main/ipc/crash-reporting.test.ts | 110 ++++++++- src/main/ipc/crash-reporting.ts | 160 ++++++------- src/main/ipc/feedback.test.ts | 145 +++++++++++- src/main/ipc/feedback.ts | 111 ++++++++- src/main/observability/tracer.test.ts | 18 ++ src/main/observability/tracer.ts | 18 +- src/main/win32-utils-async-acl.test.ts | 157 +++++++++++++ src/main/win32-utils.ts | 119 ++++++++-- src/main/window/createMainWindow.test.ts | 18 +- src/main/window/createMainWindow.ts | 14 +- src/preload/api-types.ts | 8 +- src/preload/index.ts | 3 +- .../crash-report/CrashReportDialogSurface.tsx | 86 ++++--- .../crash-report-submit-notice.test.ts | 128 +++++++++++ .../crash-report-submit-notice.ts | 134 +++++++++++ .../use-crash-report-copy.test.tsx | 133 +++++++++++ .../crash-report/use-crash-report-copy.ts | 69 ++++++ src/renderer/src/i18n/locales/en.json | 15 ++ src/renderer/src/i18n/locales/es.json | 15 ++ src/renderer/src/i18n/locales/ja.json | 15 ++ src/renderer/src/i18n/locales/ko.json | 17 +- src/renderer/src/i18n/locales/zh.json | 15 ++ src/shared/crash-reporting.ts | 13 ++ 37 files changed, 2222 insertions(+), 284 deletions(-) create mode 100644 src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts create mode 100644 src/main/crash-reporting/crash-report-copy-text.test.ts create mode 100644 src/main/crash-reporting/crash-report-copy-text.ts create mode 100644 src/main/crash-reporting/durable-crash-breadcrumb.test.ts create mode 100644 src/main/crash-reporting/durable-crash-breadcrumb.ts create mode 100644 src/main/crash-reporting/process-gone-recorder.test.ts create mode 100644 src/main/crash-reporting/process-gone-recorder.ts create mode 100644 src/main/win32-utils-async-acl.test.ts create mode 100644 src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts create mode 100644 src/renderer/src/components/crash-report/crash-report-submit-notice.ts create mode 100644 src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx create mode 100644 src/renderer/src/components/crash-report/use-crash-report-copy.ts diff --git a/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts new file mode 100644 index 00000000000..4068dcdb834 --- /dev/null +++ b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts @@ -0,0 +1,119 @@ +import os from 'node:os' +import { app } from 'electron' +import { + sanitizeCrashReportString, + type CrashReportDiagnosticBundle +} from '../../shared/crash-reporting' +import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability' +import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint' +import type { FeedbackDiagnosticBundleAttachment, FeedbackSubmitResult } from '../ipc/feedback' + +const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60 + +export type CrashDiagnosticBundleAttachment = { + readonly diagnosticBundle: CrashReportDiagnosticBundle + readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment +} + +function formatUnknownError(error: unknown): string { + return sanitizeCrashReportString(error instanceof Error ? error.message : String(error)) +} + +function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment { + return { + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log upload skipped by user' + } + } +} + +function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment { + const status = getDiagnosticsStatus() + if (!status.bundleEnabled) { + return { + diagnosticBundle: { + status: 'not_uploaded', + reason: status.disabledReason ?? 'diagnostic bundle collection is disabled' + } + } + } + + let bundle: ReturnType + try { + bundle = collectDiagnosticBundle({ + appVersion: app.getVersion(), + platform: os.platform(), + arch: os.arch(), + osRelease: os.release(), + orcaChannel: resolveDiagnosticOrcaChannel(), + // Why: Help > Report Crash is often used after relaunch, long after the + // default 30 minute support bundle window would miss the failure context. + lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES + }) + } catch (error) { + return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } } + } + + return { + diagnosticBundle: { + status: 'attached', + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + }, + feedbackDiagnosticBundle: { + bundleSubmissionId: bundle.bundleSubmissionId, + content: bundle.payload, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } + } +} + +export function prepareCrashDiagnosticBundle( + includeDiagnosticLogs: boolean +): CrashDiagnosticBundleAttachment { + return includeDiagnosticLogs + ? collectCrashDiagnosticBundleAttachment() + : skippedCrashDiagnosticBundle() +} + +export function diagnosticBundleForReportOnlyRetry( + attachment: CrashDiagnosticBundleAttachment +): CrashReportDiagnosticBundle | undefined { + const bundle = attachment.feedbackDiagnosticBundle + if (!bundle) { + return undefined + } + return { + status: 'not_uploaded', + reason: 'diagnostic log attachment could not be sent; report retried without logs', + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } +} + +export function resolveSubmittedDiagnosticBundle( + attachment: CrashDiagnosticBundleAttachment, + result: FeedbackSubmitResult +): CrashReportDiagnosticBundle { + const bundle = attachment.feedbackDiagnosticBundle + if (!bundle) { + return attachment.diagnosticBundle + } + const failure = + result.diagnosticBundleFailure ?? + (!result.ok ? { status: result.status, error: result.error } : undefined) + if (!failure) { + return attachment.diagnosticBundle + } + return { + status: 'not_uploaded', + reason: `diagnostic log attachment failed: ${formatUnknownError(failure.error)}`, + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } +} diff --git a/src/main/crash-reporting/crash-report-copy-text.test.ts b/src/main/crash-reporting/crash-report-copy-text.test.ts new file mode 100644 index 00000000000..3a8ec88803c --- /dev/null +++ b/src/main/crash-reporting/crash-report-copy-text.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { formatCrashReportCopyText } from './crash-report-copy-text' + +describe('formatCrashReportCopyText', () => { + it('appends only sanitized report and diagnostic omission failure fields', () => { + const text = formatCrashReportCopyText('[Crash Report]', { + error: 'proxy failed at C:\\Users\\alice\\Orca', + diagnosticContext: { + status: 'not_uploaded', + reason: 'timeout token=super-secret-value\n- Forged field: hidden', + internalEndpointError: 'must-not-cross-copy-boundary' + }, + internalTransportFailure: 'must-not-cross-copy-boundary' + }) + + expect(text).toContain('Submission failure:') + expect(text).toContain('Report error: proxy failed at [redacted-path]') + expect(text).toContain('Diagnostic logs not uploaded: timeout token=[redacted]') + expect(text).not.toContain('\n- Forged field') + expect(text).not.toContain('alice') + expect(text).not.toContain('must-not-cross-copy-boundary') + }) + + it('includes a sanitized uploaded ticket without accepting unrelated fields', () => { + const text = formatCrashReportCopyText('[Crash Report]', { + error: 'report link failed', + diagnosticContext: { + status: 'uploaded', + ticketId: 'ticket-123', + bundleSubmissionId: 'not-allow-listed' + } + }) + + expect(text).toContain('Diagnostic ticket uploaded but not linked: ticket-123') + expect(text).not.toContain('not-allow-listed') + }) + + it('leaves the report unchanged when failure context is invalid', () => { + expect(formatCrashReportCopyText('[Crash Report]', { error: '' })).toBe('[Crash Report]') + }) +}) diff --git a/src/main/crash-reporting/crash-report-copy-text.ts b/src/main/crash-reporting/crash-report-copy-text.ts new file mode 100644 index 00000000000..681b8bd9e85 --- /dev/null +++ b/src/main/crash-reporting/crash-report-copy-text.ts @@ -0,0 +1,53 @@ +import { + sanitizeCrashReportString, + type CrashReportCopySubmissionFailure +} from '../../shared/crash-reporting' + +function sanitizedNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const sanitized = sanitizeCrashReportString(value.replace(/[\r\n]+/g, ' ')).trim() + return sanitized || null +} + +function normalizeSubmissionFailure(value: unknown): CrashReportCopySubmissionFailure | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + const error = sanitizedNonEmptyString(record.error) + if (!error) { + return null + } + + const rawContext = record.diagnosticContext + if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) { + return { error } + } + const context = rawContext as Record + if (context.status === 'uploaded') { + const ticketId = sanitizedNonEmptyString(context.ticketId) + return ticketId ? { error, diagnosticContext: { status: 'uploaded', ticketId } } : { error } + } + if (context.status === 'not_uploaded') { + const reason = sanitizedNonEmptyString(context.reason) + return reason ? { error, diagnosticContext: { status: 'not_uploaded', reason } } : { error } + } + return { error } +} + +export function formatCrashReportCopyText(baseText: string, submissionFailure: unknown): string { + const failure = normalizeSubmissionFailure(submissionFailure) + if (!failure) { + return baseText + } + + const lines = [baseText, '', 'Submission failure:', `- Report error: ${failure.error}`] + if (failure.diagnosticContext?.status === 'uploaded') { + lines.push(`- Diagnostic ticket uploaded but not linked: ${failure.diagnosticContext.ticketId}`) + } else if (failure.diagnosticContext?.status === 'not_uploaded') { + lines.push(`- Diagnostic logs not uploaded: ${failure.diagnosticContext.reason}`) + } + return lines.join('\n') +} diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index 2dc9a87aa55..1a14a9fa2e6 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -1,7 +1,17 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { grantDirAclAsyncMock } = vi.hoisted(() => ({ + grantDirAclAsyncMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../win32-utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, grantDirAclAsync: grantDirAclAsyncMock } +}) + import { CrashReportStore } from './crash-report-store' import type { CrashReportCreateInput } from '../../shared/crash-reporting' @@ -38,6 +48,9 @@ function input(reason = 'crashed'): CrashReportCreateInput { } afterEach(async () => { + vi.restoreAllMocks() + grantDirAclAsyncMock.mockReset() + grantDirAclAsyncMock.mockResolvedValue(undefined) await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))) }) @@ -134,4 +147,72 @@ describe('CrashReportStore', () => { await expect(store.listRecent()).resolves.toHaveLength(5) }) + + it('waits for an in-flight crash write before reading the pending report', async () => { + const { store } = await createStore() + + const recordPromise = store.record(input()) + const pendingPromise = store.getLatestPending() + const report = await recordPromise + + await expect(pendingPromise).resolves.toMatchObject({ id: report.id, status: 'pending' }) + }) + + it('repairs the Windows userData ACL and retries a denied crash write', async () => { + const { store, filePath } = await createStore() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const permissionError = Object.assign(new Error('permission denied'), { code: 'EPERM' }) + const writeFileSpy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(permissionError) + + const report = await store.record(input()) + + expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath)) + expect(writeFileSpy).toHaveBeenCalledTimes(2) + await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id }) + }) + + it.each(['EPERM', 'EACCES', 'EBUSY'] as const)( + 'recovers a pending report after a transient Windows %s read failure', + async (code) => { + const { store, filePath } = await createStore() + const report = await store.record(input()) + const reloaded = new CrashReportStore(filePath) + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const readError = Object.assign(new Error('temporary read failure'), { code }) + const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError) + + await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id }) + + expect(readFileSpy).toHaveBeenCalledTimes(2) + if (code === 'EBUSY') { + expect(grantDirAclAsyncMock).not.toHaveBeenCalled() + } else { + expect(grantDirAclAsyncMock).toHaveBeenCalledOnce() + expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath)) + } + } + ) + + it('retries a transient Windows rename lock', async () => { + const { store } = await createStore() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const busyError = Object.assign(new Error('file busy'), { code: 'EBUSY' }) + const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(busyError) + + await expect(store.record(input())).resolves.toMatchObject({ status: 'pending' }) + + expect(renameSpy).toHaveBeenCalledTimes(2) + expect(grantDirAclAsyncMock).not.toHaveBeenCalled() + }) + + it('removes its temp file after a terminal write failure', async () => { + const { store, filePath } = await createStore() + const ioError = Object.assign(new Error('disk error'), { code: 'EIO' }) + vi.spyOn(fs, 'rename').mockRejectedValueOnce(ioError) + + await expect(store.record(input())).rejects.toBe(ioError) + + const entries = await fs.readdir(path.dirname(filePath)) + expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([]) + }) }) diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index e1791398e7b..35f9eb5cb48 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { app } from 'electron' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' import { formatCrashReportText, sanitizeCrashReportBreadcrumbs, @@ -14,6 +15,7 @@ import { const MAX_REPORTS = 5 const RELATED_CRASH_WINDOW_MS = 5_000 +const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250] type CrashReportFile = { reports: CrashReportRecord[] @@ -37,6 +39,46 @@ function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRe ) } +function isRetryableWindowsFileOperationError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY' +} + +async function wait(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +async function runCrashReportFileOperationWithWindowsRecovery( + directory: string, + operation: () => Promise +): Promise { + let repairedAcl = false + for (let attempt = 0; ; attempt += 1) { + try { + return await operation() + } catch (error) { + if ( + process.platform !== 'win32' || + !isRetryableWindowsFileOperationError(error) || + attempt >= WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS.length + ) { + throw error + } + if (!repairedAcl && isPermissionError(error)) { + repairedAcl = true + try { + // Why: Chromium can reset userData ACLs before startup capture or + // recovery, so both the crash write and next prompt must repair it. + await grantDirAclAsync(directory) + } catch { + // The bounded retry below still handles transient file locks. + } + } + await wait(WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS[attempt]) + } + } +} + export class CrashReportStore { private writeChain = Promise.resolve() @@ -135,7 +177,9 @@ export class CrashReportStore { mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }> ): Promise { const run = this.writeChain.then(async () => { - const reports = await this.readReports() + // Why: awaiting writeChain from inside its own callback would deadlock; + // this writer already has exclusive ownership and can read disk directly. + const reports = await this.readReportsFromDisk() const { reports: nextReports, result } = await mutate(reports) await this.writeReports(nextReports) return result @@ -148,8 +192,18 @@ export class CrashReportStore { } private async readReports(): Promise { + // Why: renderer recovery can query the one-shot startup prompt while the + // crash write is still in flight. Wait so a successful capture is visible. + await this.writeChain + return this.readReportsFromDisk() + } + + private async readReportsFromDisk(): Promise { try { - const raw = await fs.readFile(this.filePath, 'utf8') + const raw = await runCrashReportFileOperationWithWindowsRecovery( + path.dirname(this.filePath), + () => fs.readFile(this.filePath, 'utf8') + ) const parsed = JSON.parse(raw) as Partial return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : [] } catch (error) { @@ -161,9 +215,18 @@ export class CrashReportStore { } private async writeReports(reports: CrashReportRecord[]): Promise { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + const directory = path.dirname(this.filePath) const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp` - await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') - await fs.rename(tmpPath, this.filePath) + try { + await runCrashReportFileOperationWithWindowsRecovery(directory, async () => { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') + await fs.rename(tmpPath, this.filePath) + }) + } finally { + // Why: disk-full and terminal rename failures must not accumulate a new + // orphaned multi-report temp file after every crash. + await fs.rm(tmpPath, { force: true }).catch(() => {}) + } } } diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.test.ts b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts new file mode 100644 index 00000000000..78c04f44d6b --- /dev/null +++ b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer' + +type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + const flushMock = vi.fn() + return { + records, + flushMock, + push: (record) => records.push(record), + flush: flushMock, + close: vi.fn() + } +} + +let sink: CapturingSink + +beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + _resetTracerForTests() + clearCrashBreadcrumbsForTest() +}) + +describe('recordDurableCrashBreadcrumb', () => { + it('records, traces, and flushes a sanitized crash breadcrumb', () => { + recordDurableCrashBreadcrumb('process_gone_suppressed', { + source: 'renderer', + path: 'C:\\Users\\alice\\project' + }) + + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: { source: 'renderer', path: '[redacted-path]' } + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + attributes: expect.objectContaining({ + kind: 'crash-breadcrumb', + 'breadcrumb.name': 'process_gone_suppressed' + }), + exit: { _tag: 'Success' } + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('records persistence failures as failed, flushed spans', () => { + recordDurableCrashBreadcrumb( + 'crash_report_persist_failed', + { errorCode: 'EPERM' }, + 'EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca' + ) + + expect(sink.records).toEqual([ + expect.objectContaining({ + exit: expect.objectContaining({ _tag: 'Failure', cause: expect.stringContaining('EPERM') }) + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain('alice') + expect(sink.flushMock).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts new file mode 100644 index 00000000000..14cca5e1bc6 --- /dev/null +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -0,0 +1,33 @@ +import { + sanitizeCrashReportDetails, + sanitizeCrashReportString, + type CrashReportBreadcrumbData +} from '../../shared/crash-reporting' +import { flushActiveSink, startSpan } from '../observability/tracer' +import { recordCrashBreadcrumb } from './crash-breadcrumb-store' + +export function recordDurableCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData, + failureCause?: string +): void { + const sanitizedName = sanitizeCrashReportString(name) + const sanitizedData = data ? sanitizeCrashReportDetails(data) : undefined + recordCrashBreadcrumb(sanitizedName, sanitizedData) + + const span = startSpan('crash.breadcrumb', { + attributes: { + kind: 'crash-breadcrumb', + 'breadcrumb.name': sanitizedName, + ...(sanitizedData ? { 'breadcrumb.data': sanitizedData } : {}) + } + }) + if (failureCause) { + span.fail(sanitizeCrashReportString(failureCause, 1_000)) + } else { + span.end() + } + // Why: these breadcrumbs explain a missing crash record; losing one to the + // normal trace batching window would recreate the diagnostic blind spot. + flushActiveSink() +} diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index 6beebfd4926..c4a2dc714fc 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -11,6 +11,27 @@ describe('ProcessGoneDedupe', () => { expect(dedupe.shouldRecord(key, 3_000)).toBe(true) }) + it('allows an immediate retry after a failed claim is released', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + const claim = dedupe.tryClaim('renderer', 1_000) + + expect(claim).not.toBeNull() + expect(dedupe.tryClaim('renderer', 1_001)).toBeNull() + dedupe.release(claim!) + expect(dedupe.tryClaim('renderer', 1_001)).not.toBeNull() + }) + + it('does not release a newer claim when an older persistence attempt fails late', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + const oldClaim = dedupe.tryClaim('renderer', 1_000) + const currentClaim = dedupe.tryClaim('renderer', 3_000) + + expect(oldClaim).not.toBeNull() + expect(currentClaim).not.toBeNull() + dedupe.release(oldClaim!) + expect(dedupe.tryClaim('renderer', 3_001)).toBeNull() + }) + it('prunes stale keys outside the dedupe window', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index eb746a18e57..b35cccc6576 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -6,10 +6,19 @@ type ProcessGoneDedupeOptions = { maxKeys?: number } +export type ProcessGoneDedupeClaim = { + readonly key: string +} + +type ProcessGoneDedupeEntry = { + readonly recordedAt: number + readonly claim: ProcessGoneDedupeClaim +} + export class ProcessGoneDedupe { private readonly windowMs: number private readonly maxKeys: number - private readonly recentKeys = new Map() + private readonly recentKeys = new Map() constructor(options: ProcessGoneDedupeOptions = {}) { this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS @@ -17,19 +26,33 @@ export class ProcessGoneDedupe { } shouldRecord(key: string, now = Date.now()): boolean { + return this.tryClaim(key, now) !== null + } + + tryClaim(key: string, now = Date.now()): ProcessGoneDedupeClaim | null { this.prune(now) const previous = this.recentKeys.get(key) - if (previous !== undefined && now - previous < this.windowMs) { - return false + if (previous && now - previous.recordedAt < this.windowMs) { + return null } + const claim = { key } // Why: process-gone tuples come from Electron and can vary by exit code; // keep the short dedupe window without retaining stale tuples forever. this.recentKeys.delete(key) - this.recentKeys.set(key, now) + this.recentKeys.set(key, { recordedAt: now, claim }) this.prune(now) - return true + return claim + } + + release(claim: ProcessGoneDedupeClaim): void { + const current = this.recentKeys.get(claim.key) + // Why: an old failed write must not erase a newer claim for the same + // renderer after the dedupe window expires or bounded entries are evicted. + if (current?.claim === claim) { + this.recentKeys.delete(claim.key) + } } get size(): number { @@ -37,8 +60,8 @@ export class ProcessGoneDedupe { } private prune(now: number): void { - for (const [key, recordedAt] of this.recentKeys) { - if (now - recordedAt >= this.windowMs) { + for (const [key, entry] of this.recentKeys) { + if (now - entry.recordedAt >= this.windowMs) { this.recentKeys.delete(key) } } diff --git a/src/main/crash-reporting/process-gone-diagnostics.test.ts b/src/main/crash-reporting/process-gone-diagnostics.test.ts index 769ed618766..e179cec86f3 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.test.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.test.ts @@ -71,6 +71,7 @@ describe('process gone diagnostics', () => { processType: 'Utility', reason: 'killed', exitCode: 1, + expectedTeardown: 'app-shutdown', details: { name: 'Network Service', serviceName: 'network.mojom.NetworkService', @@ -82,6 +83,7 @@ describe('process gone diagnostics', () => { processType: 'Utility', reason: 'killed', exitCode: 1, + expectedTeardown: 'app-shutdown', name: 'Network Service', serviceName: 'network.mojom.NetworkService' }) diff --git a/src/main/crash-reporting/process-gone-diagnostics.ts b/src/main/crash-reporting/process-gone-diagnostics.ts index 42c8cad3125..fd93c48b88a 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.ts @@ -4,6 +4,7 @@ import { type CrashReportBreadcrumbData, type CrashReportDetailValue } from '../../shared/crash-reporting' +import type { ExpectedTeardownScope } from './process-gone-classification' type ProcessMetricLike = { pid?: unknown @@ -124,19 +125,22 @@ export function buildSuppressedProcessGoneBreadcrumbData({ processType, reason, exitCode, + expectedTeardown, details }: { source: 'renderer' | 'child' processType: string reason: string exitCode: number | null + expectedTeardown: ExpectedTeardownScope details: Record }): CrashReportBreadcrumbData { const breadcrumb: CrashReportBreadcrumbData = { source, processType, reason, - exitCode + exitCode, + expectedTeardown } const name = safeString(details.name) if (name) { diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts new file mode 100644 index 00000000000..94a67982fa8 --- /dev/null +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getVersion: () => '1.2.3-test', + getAppMetrics: () => [] + } +})) + +import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { ProcessGoneDedupe } from './process-gone-dedupe' +import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer' + +type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + const flushMock = vi.fn() + return { + records, + flushMock, + push: (record) => records.push(record), + flush: flushMock, + close: vi.fn() + } +} + +function event(overrides: Partial = {}): ProcessGoneCrashEvent { + return { + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'none', + details: { processType: 'renderer' }, + ...overrides + } +} + +let sink: CapturingSink + +beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + vi.restoreAllMocks() + _resetTracerForTests() + clearCrashBreadcrumbsForTest() +}) + +describe('recordProcessGoneCrash', () => { + it('durably records when the crash report store is unavailable', () => { + recordProcessGoneCrash(null, event(), new ProcessGoneDedupe()) + + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'crash_report_store_unavailable', + data: expect.objectContaining({ + source: 'renderer', + expectedTeardown: 'none' + }) + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('durably records why an expected renderer teardown was suppressed', () => { + const record = vi.fn() + + recordProcessGoneCrash( + { record } as never, + event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload' }), + new ProcessGoneDedupe() + ) + + expect(record).not.toHaveBeenCalled() + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ expectedTeardown: 'renderer-reload' }) + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + attributes: expect.objectContaining({ + 'breadcrumb.name': 'process_gone_suppressed' + }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('persists a report and flushes the process-gone trace before recovery', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ source: 'renderer', reason: 'crashed', exitCode: 5 }) + ) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'electron.process_gone', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('still persists the report when the forced trace flush fails', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + sink.flushMock.mockImplementation(() => { + throw new Error('trace disk unavailable') + }) + + expect(() => + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + ).not.toThrow() + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + }) + + it('still persists the report when the trace sink handoff fails', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + sink.push = () => { + throw new Error('trace rotation failed') + } + + expect(() => + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + ).not.toThrow() + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + }) + + it('durably records a sanitized crash-report persistence failure', async () => { + const persistError = Object.assign( + new Error('EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca\\crash-reports.json'), + { code: 'EPERM' } + ) + const record = vi.fn().mockRejectedValue(persistError) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => { + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'crash_report_persist_failed', + data: expect.objectContaining({ errorCode: 'EPERM' }) + }) + ]) + ) + }) + expect(sink.records).toHaveLength(2) + expect(sink.records[1]).toEqual( + expect.objectContaining({ + name: 'crash.breadcrumb', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + expect(JSON.stringify(sink.records)).not.toContain('alice') + expect(sink.flushMock).toHaveBeenCalledTimes(2) + }) + + it('keeps null persistence rejections inside the fail-open diagnostic path', async () => { + const record = vi.fn().mockRejectedValue(null) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'crash_report_persist_failed', + data: expect.objectContaining({ errorName: 'object', errorMessage: 'null' }) + }) + ]) + ) + ) + }) + + it('allows the same renderer crash to retry after persistence fails', async () => { + const record = vi + .fn() + .mockRejectedValueOnce(new Error('disk unavailable')) + .mockResolvedValueOnce({ id: 'report-2' }) + const dedupe = new ProcessGoneDedupe() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), dedupe) + await vi.waitFor(() => + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })]) + ) + ) + recordProcessGoneCrash({ record } as never, event(), dedupe) + + await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts new file mode 100644 index 00000000000..daec429ae3e --- /dev/null +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -0,0 +1,137 @@ +import os from 'node:os' +import { app } from 'electron' +import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting' +import type { CrashReportStore } from './crash-report-store' +import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb' +import { + shouldRecordProcessGoneCrash, + type ExpectedTeardownScope, + type ProcessGoneSource +} from './process-gone-classification' +import { + buildProcessGoneCrashDetails, + buildSuppressedProcessGoneBreadcrumbData +} from './process-gone-diagnostics' +import { + getProcessGoneDedupeKey, + processGoneDedupe, + type ProcessGoneDedupe +} from './process-gone-dedupe' +import { flushActiveSink, startSpan } from '../observability/tracer' + +export type ProcessGoneCrashEvent = { + source: ProcessGoneSource + processType: string + reason: string + exitCode: number | null + expectedTeardown: ExpectedTeardownScope + details: Record +} + +type CrashReportRecorderStore = Pick + +function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { + return buildSuppressedProcessGoneBreadcrumbData(event) +} + +function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined + return { + ...processGoneBreadcrumbData(event), + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)), + ...(errorCode ? { errorCode } : {}) + } +} + +export function recordProcessGoneCrash( + store: CrashReportRecorderStore | null, + event: ProcessGoneCrashEvent, + dedupe: ProcessGoneDedupe = processGoneDedupe +): void { + if (!isCrashReportReason(event.reason)) { + return + } + if ( + !shouldRecordProcessGoneCrash({ + source: event.source, + processType: event.processType, + serviceName: + typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined, + reason: event.reason, + exitCode: event.exitCode, + expectedTeardown: event.expectedTeardown + }) + ) { + recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event)) + return + } + if (!store) { + recordDurableCrashBreadcrumb( + 'crash_report_store_unavailable', + processGoneBreadcrumbData(event), + 'Crash report store unavailable' + ) + return + } + + const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode) + const claim = dedupe.tryClaim(key) + if (!claim) { + return + } + const crashDetails = buildProcessGoneCrashDetails(event.details) + const breadcrumbs = getCrashBreadcrumbSnapshot() + const span = startSpan('electron.process_gone', { + attributes: { + 'crash.source': event.source, + 'crash.process_type': event.processType, + 'crash.reason': event.reason, + ...(event.exitCode !== null ? { 'crash.exit_code': event.exitCode } : {}), + 'app.version': app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + details: crashDetails, + breadcrumbs + } + }) + // Why: a renderer crash can be followed by another process exit before the + // trace batch window closes, so make the primary signal durable immediately. + span.fail( + `${event.source} process gone: ${event.processType} ${event.reason} (${event.exitCode ?? 'unknown'})` + ) + flushActiveSink() + + void store + .record({ + source: event.source, + processType: event.processType, + reason: event.reason, + exitCode: event.exitCode, + appVersion: app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron ?? 'unknown', + chromeVersion: process.versions.chrome ?? 'unknown', + details: crashDetails, + breadcrumbs + }) + .catch((error) => { + dedupe.release(claim) + console.error('[crash-reporting] Failed to persist crash report:', error) + const data = persistFailureData(event, error) + recordDurableCrashBreadcrumb( + 'crash_report_persist_failed', + data, + `${String(data.errorName)}: ${String(data.errorMessage)}` + ) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 975e3a4596b..27e9aee2aa4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,7 +26,6 @@ import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' -import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' import { classifyError } from './telemetry/classify-error' @@ -159,28 +158,21 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles import { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { - getCrashBreadcrumbSnapshot, recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' import { CrashReportStore } from './crash-reporting/crash-report-store' import { - shouldRecordProcessGoneCrash, shouldRecoverRendererAfterProcessGone, type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' -import { - buildProcessGoneCrashDetails, - buildSuppressedProcessGoneBreadcrumbData -} from './crash-reporting/process-gone-diagnostics' -import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, shouldDriveSyntheticAgentTitleFromHook, @@ -833,14 +825,6 @@ function openMainWindow(): BrowserWindow { webContentsId ) }, - shouldRecordRendererCrash: (details, webContentsId) => - shouldRecordProcessGoneCrash({ - source: 'renderer', - processType: 'renderer', - reason: details.reason, - exitCode: details.exitCode ?? null, - expectedTeardown: getExpectedTeardownScope(webContentsId) - }), shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, @@ -1229,75 +1213,14 @@ function recordProcessGoneCrash( details: Record, webContentsId?: number ): void { - if (!crashReports || !isCrashReportReason(reason)) { - return - } - if ( - !shouldRecordProcessGoneCrash({ - source, - processType, - serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId) - }) - ) { - recordCrashBreadcrumb( - 'process_gone_suppressed', - buildSuppressedProcessGoneBreadcrumbData({ - source, - processType, - reason, - exitCode, - details - }) - ) - return - } - const key = getProcessGoneDedupeKey(source, processType, reason, exitCode) - if (!processGoneDedupe.shouldRecord(key)) { - return - } - const crashDetails = buildProcessGoneCrashDetails(details) - const span = startSpan('electron.process_gone', { - attributes: { - 'crash.source': source, - 'crash.process_type': processType, - 'crash.reason': reason, - ...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}), - 'app.version': app.getVersion(), - platform: process.platform, - osRelease: os.release(), - arch: process.arch, - electronVersion: process.versions.electron, - chromeVersion: process.versions.chrome, - details: crashDetails, - breadcrumbs: getCrashBreadcrumbSnapshot() - } + recordProcessGoneCrashEvent(crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details }) - // Why: renderer/child crashes belong in the local trace lane so the - // diagnostic bundle has the same process-gone signal as the startup prompt. - span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`) - void crashReports - .record({ - source, - processType, - reason, - exitCode, - appVersion: app.getVersion(), - platform: process.platform, - osRelease: os.release(), - arch: process.arch, - electronVersion: process.versions.electron, - chromeVersion: process.versions.chrome, - details: crashDetails, - // Why: breadcrumbs stay memory-only during normal operation. Persist a - // snapshot only after Electron reports a crash-like process exit. - breadcrumbs: getCrashBreadcrumbSnapshot() - }) - .catch((error) => { - console.error('[crash-reporting] Failed to persist crash report:', error) - }) } function shutdownWatchersOnce(): Promise { diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 04432899abb..2c28cfd6c77 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -185,6 +185,42 @@ describe('registerCrashReportingHandlers', () => { expect(listRecent).not.toHaveBeenCalled() }) + it('copies sanitized submission and diagnostic omission failures for a captured report', async () => { + const pending = report('pending', 'crash-copy-failure') + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, { + reportId: pending.id, + notes: 'current notes', + submissionFailure: { + error: 'fallback failed at C:\\Users\\alice\\Orca', + diagnosticContext: { + status: 'not_uploaded', + reason: 'attachment token=super-secret-value', + internalEndpointError: 'must-not-cross-copy-boundary' + }, + diagnosticBundleFailure: 'must-not-cross-copy-boundary' + } + }) + + expect(result).toEqual({ ok: true }) + const copiedText = String(clipboardWriteTextMock.mock.calls[0]?.[0]) + expect(copiedText).toContain('Report ID: crash-copy-failure') + expect(copiedText).toContain('Submission failure:') + expect(copiedText).toContain('Report error: fallback failed at [redacted-path]') + expect(copiedText).toContain('Diagnostic logs not uploaded: attachment token=[redacted]') + expect(copiedText).not.toContain('alice') + expect(copiedText).not.toContain('must-not-cross-copy-boundary') + }) + it('returns dismissed unsent reports for the manual Help menu entry', async () => { const dismissed = report('dismissed', 'crash-help-menu') registerCrashReportingHandlers({ @@ -244,7 +280,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(markSent).toHaveBeenCalledWith(pending.id) }) @@ -294,7 +331,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(submitFeedbackMock).toHaveBeenCalledWith( expect.objectContaining({ feedback: expect.stringContaining('Status: attached') }) @@ -347,6 +385,52 @@ describe('registerCrashReportingHandlers', () => { ) }) + it('marks the report sent when transport retries successfully without diagnostic logs', async () => { + const pending = report('pending', 'crash-degraded') + const sent = report('sent', pending.id) + const markSent = vi.fn(async () => sent) + submitFeedbackMock.mockResolvedValueOnce({ + ok: true, + diagnosticBundleFailure: { status: 413, error: 'status 413' } + }) + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent, + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: pending.id, + notes: 'manual report', + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + + expect(result).toEqual({ + ok: true, + report: sent, + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: status 413', + bundleSubmissionId: 'bundleabcdefghijklmnop', + bytes: 25, + spanCount: 1 + } + }) + expect(markSent).toHaveBeenCalledWith(pending.id) + expect(submitFeedbackMock).toHaveBeenCalledWith( + expect.objectContaining({ + feedback: expect.stringContaining('Status: attached'), + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') + }) + ) + }) + it('submits the crash report without logs when the user excludes diagnostic logs', async () => { registerCrashReportingHandlers({ getById: vi.fn(async () => null), @@ -459,7 +543,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id) }) @@ -492,8 +577,9 @@ describe('registerCrashReportingHandlers', () => { const markSent = vi.fn() submitFeedbackMock.mockResolvedValue({ ok: false, - status: 500, - error: 'status 500' + status: null, + error: 'report-only network failed', + diagnosticBundleFailure: { status: 500, error: 'status 500' } }) registerCrashReportingHandlers({ getById: vi.fn(async () => pending), @@ -514,10 +600,18 @@ describe('registerCrashReportingHandlers', () => { expect(result).toEqual({ ok: false, - status: 500, - error: 'status 500', - report: pending + status: null, + error: 'report-only network failed', + report: pending, + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: status 500', + bundleSubmissionId: 'bundleabcdefghijklmnop', + bytes: 25, + spanCount: 1 + } }) + expect(result).not.toHaveProperty('diagnosticBundleFailure') expect(submitFeedbackMock).toHaveBeenCalledWith( expect.objectContaining({ diagnosticBundle: { diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 7d60da1799f..ac424d834a8 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -4,6 +4,7 @@ import os from 'node:os' import { app, clipboard, ipcMain } from 'electron' import { type CrashReportBreadcrumbData, + type CrashReportCopyDiagnosticsArgs, type CrashReportDiagnosticBundle, type ReactErrorBoundaryReportArgs, type ReactErrorBoundaryReportResult, @@ -20,14 +21,17 @@ import { getCrashBreadcrumbSnapshot, recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability' -import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint' import { startSpan } from '../observability/tracer' -import type { FeedbackDiagnosticBundleAttachment } from './feedback' +import { + diagnosticBundleForReportOnlyRetry, + prepareCrashDiagnosticBundle, + resolveSubmittedDiagnosticBundle +} from '../crash-reporting/crash-feedback-diagnostic-bundle' import { assertClipboardTextWriteWithinLimit, isClipboardTextWriteTooLargeError } from '../../shared/clipboard-text' +import { formatCrashReportCopyText } from '../crash-reporting/crash-report-copy-text' const inFlightSubmissions = new Set() const submittedReportIds = new Set() @@ -37,7 +41,6 @@ const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000 const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2 const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256 const MAX_SUBMITTED_REPORT_IDS = 256 -const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60 const REACT_ERROR_BOUNDARY_SURFACES = new Set([ 'app-root', @@ -52,11 +55,6 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set - try { - bundle = collectDiagnosticBundle({ - appVersion: app.getVersion(), - platform: os.platform(), - arch: os.arch(), - osRelease: os.release(), - orcaChannel: resolveDiagnosticOrcaChannel(), - // Why: Help > Report Crash is often used after relaunch, long after the - // default 30 minute support bundle window would miss the failure context. - lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES - }) - } catch (error) { - return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } } - } - - return { - diagnosticBundle: { - status: 'attached', - bundleSubmissionId: bundle.bundleSubmissionId, - bytes: bundle.bytes, - spanCount: bundle.spanCount - }, - feedbackDiagnosticBundle: { - bundleSubmissionId: bundle.bundleSubmissionId, - content: bundle.payload, - bytes: bundle.bytes, - spanCount: bundle.spanCount - } - } -} - export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:getLatestPending') ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) @@ -412,15 +354,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:copyLatestDiagnostics') ipcMain.handle( 'crashReports:copyLatestDiagnostics', - async (_event, args?: { reportId?: string; notes?: string }) => { + async (_event, args?: CrashReportCopyDiagnosticsArgs) => { const report = await getRequestedCrashReport(store, args) - if (!report) { - clipboard.writeText(buildUncapturedCrashReportText(args?.notes)) - return { ok: true as const } - } + const baseText = report + ? formatCrashReportText(report, args?.notes) + : buildUncapturedCrashReportText(args?.notes) try { clipboard.writeText( - assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes)) + assertClipboardTextWriteWithinLimit( + formatCrashReportCopyText(baseText, args?.submissionFailure) + ) ) } catch (error) { if (isClipboardTextWriteTooLargeError(error)) { @@ -448,11 +391,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { async (_event, args: CrashReportSubmitArgs): Promise => { const report = await getRequestedCrashReport(store, args) if (!report) { - const diagnosticUpload = - args.includeDiagnosticLogs === false - ? skippedCrashDiagnosticBundle() - : collectCrashDiagnosticBundleAttachment() + const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false) const diagnosticBundle = diagnosticUpload.diagnosticBundle + const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload) const result = await submitFeedback({ feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle), submissionType: 'crash', @@ -460,14 +401,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { githubLogin: args.githubLogin, githubEmail: args.githubEmail, ...(diagnosticUpload.feedbackDiagnosticBundle - ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle } + ? { + diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle, + feedbackWithoutDiagnosticBundle: buildUncapturedCrashReportText( + args.notes, + reportOnlyDiagnosticBundle + ) + } : {}) }) + const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result) return result.ok - ? { ok: true, report: null, diagnosticBundle } + ? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle } : { - ...result, - report: null + // Why: the transport-only attachment failure may contain raw + // endpoint detail; only its sanitized bundle reason crosses IPC. + ok: false, + status: result.status, + error: result.error, + report: null, + diagnosticBundle: submittedDiagnosticBundle } } const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed') @@ -491,11 +444,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { inFlightSubmissions.add(report.id) try { - const diagnosticUpload = - args.includeDiagnosticLogs === false - ? skippedCrashDiagnosticBundle() - : collectCrashDiagnosticBundleAttachment() + const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false) const diagnosticBundle = diagnosticUpload.diagnosticBundle + const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload) const result = await submitFeedback({ feedback: formatCrashReportText(report, args.notes, diagnosticBundle), submissionType: 'crash', @@ -503,13 +454,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { githubLogin: args.githubLogin, githubEmail: args.githubEmail, ...(diagnosticUpload.feedbackDiagnosticBundle - ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle } + ? { + diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle, + feedbackWithoutDiagnosticBundle: formatCrashReportText( + report, + args.notes, + reportOnlyDiagnosticBundle + ) + } : {}) }) + const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result) if (!result.ok) { return { - ...result, - report + // Why: keep the renderer contract allow-listed instead of leaking + // the transport's internal diagnosticBundleFailure object. + ok: false, + status: result.status, + error: result.error, + report, + diagnosticBundle: submittedDiagnosticBundle } } rememberSubmittedReportId(report.id) @@ -518,21 +482,37 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { // Why: startup prompts are dismissed before the user can send from // the still-open dialog, so successful uploads must update storage. const sent = await store.markDismissedSent(report.id) - return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: sent ?? { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } catch (error) { console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error) - return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } } try { const sent = await store.markSent(report.id) - return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: sent ?? { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } catch (error) { // Why: the upstream submission already succeeded. A local persistence // failure must not present as upload failure or invite duplicate sends // during this app session. console.error('[crash-reporting] Failed to mark crash report sent:', error) - return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } } finally { inFlightSubmissions.delete(report.id) diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts index 1d8c3053885..52de8251d1b 100644 --- a/src/main/ipc/feedback.test.ts +++ b/src/main/ipc/feedback.test.ts @@ -22,9 +22,34 @@ function okResponse(): Response { return { ok: true, status: 200 } as unknown as Response } -function postedBody(): Record { - const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined - return JSON.parse(String(init?.body)) as Record +function errorResponse(status: number): Response { + return { ok: false, status } as unknown as Response +} + +function requestInit(callIndex = 0): RequestInit { + return fetchMock.mock.calls[callIndex]?.[1] as RequestInit +} + +function postedBody(callIndex = 0): Record { + return JSON.parse(String(requestInit(callIndex).body)) as Record +} + +function diagnosticSubmitArgs(): Parameters[0] { + return { + feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached', + feedbackWithoutDiagnosticBundle: + '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', + submissionType: 'crash', + submitAnonymously: true, + githubLogin: null, + githubEmail: null, + diagnosticBundle: { + bundleSubmissionId: 'bundleabcdefghijklmnop', + content: '{"type":"bundle-header"}\n', + bytes: 25, + spanCount: 1 + } + } } describe('submitFeedback', () => { @@ -139,6 +164,118 @@ describe('submitFeedback', () => { expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle') }) + it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 413, error: 'status 413' } + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(0).body).toBeInstanceOf(FormData) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(1).headers).toEqual({ + 'Content-Type': 'application/json' + }) + expect(postedBody(1)).toMatchObject({ + feedback: + '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', + submissionType: 'crash' + }) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a diagnostic attachment server error as report-only JSON on the fallback API', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(500)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 500, error: 'status 500' } + }) + + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' }) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a diagnostic attachment network error as report-only JSON on the fallback API', async () => { + fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) + fetchMock.mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: null, error: 'attachment network failed' } + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(requestInit(1).body).not.toBeInstanceOf(FormData) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => { + vi.useFakeTimers() + fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text'))) + }) + }) + fetchMock.mockResolvedValueOnce(okResponse()) + const result = submitFeedback(diagnosticSubmitArgs()) + + await vi.advanceTimersByTimeAsync(59_999) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(result).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' } + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a proxy-rejected diagnostic attachment as website JSON', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 403, error: 'status 403' } + }) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(1).body).not.toBeInstanceOf(FormData) + }) + + it.each([401, 409, 429])( + 'does not retry a diagnostic attachment rejected with status %s', + async (status) => { + fetchMock.mockResolvedValueOnce(errorResponse(status)) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: false, + status, + error: `status ${status}` + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it('preserves attachment and report-only failures when the degraded retry fails', async () => { + fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) + fetchMock.mockRejectedValueOnce(new Error('report-only network failed')) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: false, + status: null, + error: 'report-only network failed', + diagnosticBundleFailure: { status: null, error: 'attachment network failed' } + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('falls back when the primary feedback request stalls', async () => { vi.useFakeTimers() fetchMock.mockImplementation((url: string, init?: RequestInit) => { @@ -184,7 +321,7 @@ describe('submitFeedback', () => { await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: false, status: null, - error: 'fallback aborted' + error: 'request timed out after 10 seconds' }) expect(fetchMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index 16c159f6a4c..d9fa48e3c47 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -9,7 +9,11 @@ import { app, ipcMain, net } from 'electron' const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback' const FEEDBACK_API_FALLBACK_URL = 'https://api.onorca.dev/v1/feedback' const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000 +const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000 const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson' +// Why: corporate filters can reject multipart with 403 while allowing the +// small JSON report, so content-shaped failures should shed the attachment. +const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422]) export type FeedbackSubmissionType = 'feedback' | 'crash' @@ -39,13 +43,21 @@ type FeedbackSubmitBody = { diagnosticBundle?: FeedbackDiagnosticBundleAttachment } +export type FeedbackRequestFailure = { + status: number | null + error: string +} + export type FeedbackSubmitResult = - | { ok: true } - | { ok: false; status: number | null; error: string } + | { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure } + | ({ ok: false } & FeedbackRequestFailure & { + diagnosticBundleFailure?: FeedbackRequestFailure + }) type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & { submissionType?: FeedbackSubmissionType diagnosticBundle?: FeedbackDiagnosticBundleAttachment + feedbackWithoutDiagnosticBundle?: string } // Why: the Slack notification and any follow-up investigation need to know @@ -74,11 +86,15 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { } } -async function postFeedback(url: string, body: FeedbackSubmitBody): Promise { +async function postFeedback( + url: string, + body: FeedbackSubmitBody, + timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS +): Promise { const controller = new AbortController() // Why: a silent feedback endpoint should not leave IPC or crash-report // submission flows pending forever. - const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const init: RequestInit = { method: 'POST', @@ -86,6 +102,13 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise= 500) { + return FEEDBACK_API_FALLBACK_URL + } + return null +} + +async function submitFeedbackWithoutDiagnosticBundle( + url: string, + body: FeedbackSubmitBody, + diagnosticBundleFailure: FeedbackRequestFailure +): Promise { + try { + const response = await postFeedback(url, body) + if (response.ok) { + return { ok: true, diagnosticBundleFailure } + } + return { ok: false, ...responseFailure(response), diagnosticBundleFailure } + } catch (error) { + return { ok: false, ...errorFailure(error), diagnosticBundleFailure } + } +} + +async function submitFeedbackWithDiagnosticBundle( + body: FeedbackSubmitBody, + bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null +): Promise { + try { + // Why: diagnostic bundles can approach 4 MiB and need more upload time than + // the small JSON report-only path, especially on constrained connections. + const response = await postFeedback( + FEEDBACK_API_URL, + body, + FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS + ) + if (response.ok) { + return { ok: true } + } + const failure = responseFailure(response) + if (bodyWithoutDiagnosticBundle) { + const retryUrl = diagnosticRetryUrl(response.status) + if (retryUrl) { + return submitFeedbackWithoutDiagnosticBundle(retryUrl, bodyWithoutDiagnosticBundle, failure) + } + } + return { ok: false, ...failure } + } catch (error) { + const failure = errorFailure(error) + return bodyWithoutDiagnosticBundle + ? submitFeedbackWithoutDiagnosticBundle( + FEEDBACK_API_FALLBACK_URL, + bodyWithoutDiagnosticBundle, + failure + ) + : { ok: false, ...failure } + } +} + export async function submitFeedback( args: InternalFeedbackSubmitArgs ): Promise { const body = buildSubmitBody(args) + if (body.diagnosticBundle) { + const bodyWithoutDiagnosticBundle = + args.feedbackWithoutDiagnosticBundle !== undefined + ? buildSubmitBody({ + ...args, + feedback: args.feedbackWithoutDiagnosticBundle, + diagnosticBundle: undefined + }) + : null + return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle) + } try { const res = await postFeedback(FEEDBACK_API_URL, body) if (res.ok) { diff --git a/src/main/observability/tracer.test.ts b/src/main/observability/tracer.test.ts index d1bec74ab98..d2859436138 100644 --- a/src/main/observability/tracer.test.ts +++ b/src/main/observability/tracer.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { _resetTracerForTests, + flushActiveSink, getActiveSpanContext, setActiveSink, startSpan, @@ -69,6 +70,23 @@ describe('tracer — basic span lifecycle', () => { expect(r.exit.cause).toBe('user-cancelled') }) + it('keeps forced sink flush failures from escaping crash boundaries', () => { + sink.flush = () => { + throw new Error('disk unavailable') + } + + expect(() => flushActiveSink()).not.toThrow() + }) + + it('keeps sink handoff failures from escaping instrumented operations', async () => { + sink.push = () => { + throw new Error('trace rotation failed') + } + + expect(() => startSpan('test').fail('boom')).not.toThrow() + await expect(withSpan('async-test', async () => 'result')).resolves.toBe('result') + }) + it('end() is idempotent — second call is a no-op', () => { const span = startSpan('test') span.end() diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts index 3ef93449cf0..c151aa6a2fb 100644 --- a/src/main/observability/tracer.ts +++ b/src/main/observability/tracer.ts @@ -112,6 +112,17 @@ export function setActiveSink(sink: TracerSink | null): void { activeSink = sink } +/** Force records already handed to the tracer onto disk. Reserve this for + * crash boundaries where the process may not survive the normal batch window. */ +export function flushActiveSink(): void { + try { + activeSink?.flush() + } catch { + // Why: a disk/rotation failure in optional local diagnostics must never + // turn a recoverable renderer crash into a main-process crash. + } +} + /** Get the current parent context, or `undefined` if we are at the top of * the trace tree. Renderer-IPC entry points capture this, embed it in * span-event attributes (so cross-process spans can be visually linked @@ -221,7 +232,12 @@ export function startSpan( // Wrap in a `type: 'effect-span'` envelope so the NDJSON file is // compatible with Effect-style span output. Effect-oriented consumers // (the LGTM stack, jq cookbooks) can read the file unchanged. - activeSink?.push({ type: 'effect-span', ...redacted }) + try { + activeSink?.push({ type: 'effect-span', ...redacted }) + } catch { + // Why: tracing is optional; a local file rotation failure must not turn + // the instrumented operation into an application failure. + } } return { diff --git a/src/main/win32-utils-async-acl.test.ts b/src/main/win32-utils-async-acl.test.ts new file mode 100644 index 00000000000..22c9ff992c8 --- /dev/null +++ b/src/main/win32-utils-async-acl.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock +})) + +type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void + +const originalUsername = process.env.USERNAME + +beforeEach(() => { + vi.resetModules() + execFileMock.mockReset() + execFileSyncMock.mockReset() +}) + +afterEach(() => { + if (originalUsername === undefined) { + delete process.env.USERNAME + } else { + process.env.USERNAME = originalUsername + } +}) + +describe('grantDirAclAsync', () => { + it('keeps icacls off the synchronous main-process path', async () => { + process.env.USERNAME = 'alice' + let complete: ExecCallback | undefined + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + complete = callback + return {} as never + } + ) + const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils') + + const pending = grantDirAclAsync('C:\\Users\\alice\\Orca') + await Promise.resolve() + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalledWith( + getIcaclsExePath(), + ['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'], + { encoding: 'utf-8', windowsHide: true, timeout: 10_000 }, + expect.any(Function) + ) + complete?.(null, '', '') + await expect(pending).resolves.toBeUndefined() + }) + + it('resolves a missing username through asynchronous whoami before icacls', async () => { + delete process.env.USERNAME + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') + + await grantDirAclAsync('C:\\Orca') + + expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([ + getWhoamiExePath(), + ['/user', '/fo', 'csv', '/nh'], + { encoding: 'utf-8', windowsHide: true, timeout: 5000 } + ]) + expect(execFileMock.mock.calls[1]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-123:(OI)(CI)(F)' + ]) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('does not overwrite a concurrent cached identity with a late async result', async () => { + delete process.env.USERNAME + let completeWhoami: ExecCallback | undefined + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + completeWhoami = callback + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n') + const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils') + + const pending = grantDirAclAsync('C:\\Orca') + await Promise.resolve() + expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456') + completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '') + await pending + + expect(execFileMock.mock.calls[1]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-456:(OI)(CI)(F)' + ]) + }) + + it('retries identity resolution after a transient whoami failure', async () => { + delete process.env.USERNAME + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(new Error('whoami timed out'), '', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') + + await grantDirAclAsync('C:\\Orca') + await grantDirAclAsync('C:\\Orca') + + expect( + execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath()) + ).toHaveLength(2) + expect(execFileMock.mock.calls[2]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-123:(OI)(CI)(F)' + ]) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index a4b8dae1286..264ed6e6604 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -1,7 +1,23 @@ -import { execFileSync } from 'node:child_process' +import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process' import { delimiter, join } from 'node:path' import { existsSync } from 'node:fs' +function execFileWithoutBlocking( + command: string, + args: string[], + options: ExecFileOptionsWithStringEncoding +): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, options, (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + }) + }) +} + /** * Full path to icacls.exe. Electron's main process may have a stripped PATH * that excludes System32, causing bare `icacls` to throw ENOENT. @@ -10,6 +26,11 @@ export function getIcaclsExePath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe` } +/** Absolute path because service-launched Electron can omit System32 from PATH. */ +export function getWhoamiExePath(): string { + return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe` +} + /** * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). @@ -78,13 +99,10 @@ export function isPermissionError(error: unknown): boolean { // and hardened envs where USERNAME is unset. Fall back to the SID via // `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative // and always available on Windows. Cached because it never changes in-process. -let cachedIdentity: string | null | undefined +let cachedIdentity: string | undefined +let pendingIdentityResolution: Promise | null = null -export function resolveCurrentWindowsIdentity(): string | null { - return resolveCurrentIdentity() -} - -function resolveCurrentIdentity(): string | null { +function cachedOrEnvironmentIdentity(): string | undefined { if (cachedIdentity !== undefined) { return cachedIdentity } @@ -92,20 +110,75 @@ function resolveCurrentIdentity(): string | null { cachedIdentity = process.env.USERNAME return cachedIdentity } + return undefined +} + +function identityFromWhoamiOutput(output: string): string | null { + // CSV columns: "DOMAIN\\user","S-1-5-21-..." + const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim()) + return sidMatch ? `*${sidMatch[1]}` : null +} + +export function resolveCurrentWindowsIdentity(): string | null { + return resolveCurrentIdentity() +} + +function resolveCurrentIdentity(): string | null { + const knownIdentity = cachedOrEnvironmentIdentity() + if (knownIdentity !== undefined) { + return knownIdentity + } try { - const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { + const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 - }).trim() - // CSV columns: "DOMAIN\\user","S-1-5-21-..." - const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output) - cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null + }) + const resolvedIdentity = identityFromWhoamiOutput(output) + if (resolvedIdentity) { + cachedIdentity = resolvedIdentity + } + return resolvedIdentity } catch { - cachedIdentity = null + return null } - return cachedIdentity +} + +async function resolveCurrentIdentityAsync(): Promise { + const knownIdentity = cachedOrEnvironmentIdentity() + if (knownIdentity !== undefined) { + return knownIdentity + } + if (!pendingIdentityResolution) { + pendingIdentityResolution = (async () => { + try { + const stdout = await execFileWithoutBlocking( + getWhoamiExePath(), + ['/user', '/fo', 'csv', '/nh'], + { + encoding: 'utf-8', + windowsHide: true, + timeout: 5000 + } + ) + // Why: a synchronous caller may resolve identity while async whoami + // is in flight; its authoritative cached result must win the race. + const resolvedIdentity = identityFromWhoamiOutput(stdout) + if (cachedIdentity === undefined && resolvedIdentity) { + cachedIdentity = resolvedIdentity + } + return cachedIdentity ?? resolvedIdentity + } catch { + // Why: transient service/PATH failures must not permanently disable + // ACL repair for every later crash attempt in this process. + return cachedIdentity ?? null + } + })().finally(() => { + pendingIdentityResolution = null + }) + } + return pendingIdentityResolution } /** @@ -138,6 +211,24 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): }) } +export async function grantDirAclAsync(dirPath: string): Promise { + const identity = await resolveCurrentIdentityAsync() + if (!identity) { + return + } + // Why: crash recovery runs on Electron's main thread; an asynchronous + // icacls child keeps its worst-case timeout from freezing every window. + await execFileWithoutBlocking( + getIcaclsExePath(), + [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`], + { + encoding: 'utf-8', + windowsHide: true, + timeout: 10_000 + } + ) +} + /** * Resolve spawn parameters for a command that may be a Windows batch script. * diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index b72be7962ea..54b4728f7cd 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -2390,7 +2390,7 @@ describe('createMainWindow', () => { expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142) }) - it('passes the renderer webContents id through crash classification callbacks', () => { + it('passes the renderer webContents id through crash recording and recovery callbacks', () => { vi.useFakeTimers() const windowHandlers: Record void> = {} @@ -2423,13 +2423,11 @@ describe('createMainWindow', () => { return browserWindowInstance }) const onRendererProcessGone = vi.fn() - const shouldRecordRendererCrash = vi.fn(() => true) const shouldRecoverRenderer = vi.fn(() => true) try { createMainWindow(null, { onRendererProcessGone, - shouldRecordRendererCrash, shouldRecoverRenderer }) @@ -2437,7 +2435,6 @@ describe('createMainWindow', () => { windowHandlers['render-process-gone']?.({} as never, details) vi.advanceTimersByTime(250) - expect(shouldRecordRendererCrash).toHaveBeenCalledWith(details, 424) expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424) expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424) } finally { @@ -2445,9 +2442,10 @@ describe('createMainWindow', () => { } }) - it('does not notify the crash recorder for an expected renderer teardown', () => { + it('forwards expected renderer teardowns so the recorder can diagnose suppression', () => { const windowHandlers: Record void> = {} const webContents = { + id: 142, on: vi.fn((event, handler) => { windowHandlers[event] = handler }), @@ -2476,10 +2474,7 @@ describe('createMainWindow', () => { }) const onRendererProcessGone = vi.fn() - createMainWindow(null, { - onRendererProcessGone, - shouldRecordRendererCrash: () => false - }) + createMainWindow(null, { onRendererProcessGone }) windowHandlers['render-process-gone']?.( {} as never, @@ -2489,7 +2484,10 @@ describe('createMainWindow', () => { } as Electron.RenderProcessGoneDetails ) - expect(onRendererProcessGone).not.toHaveBeenCalled() + expect(onRendererProcessGone).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'killed', exitCode: 15 }), + expect.any(Number) + ) consoleError.mockRestore() }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 1a30178a4e2..ff66b5ebc16 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -111,13 +111,6 @@ type CreateMainWindowOptions = { details: Electron.RenderProcessGoneDetails, webContentsId: number ) => void - /** Returns true when a renderer loss should be reported as a crash. Why: - * intentional reload/update/quit paths can emit crash-like `killed` - * renderer exits, but surfacing those as crash reports is noise. */ - shouldRecordRendererCrash?: ( - details: Electron.RenderProcessGoneDetails, - webContentsId: number - ) => boolean /** Returns true when Orca should reload after an unexpected renderer loss. * Why: update relaunch and app quit intentionally tear down child * processes; recovering those paths can fight Electron's shutdown. */ @@ -713,10 +706,9 @@ export function createMainWindow( resetShortcutRecorderFocus() // Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL // after a confirmed close; that is window lifecycle noise, not a crash. - if ( - !windowClosing && - opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false - ) { + if (!windowClosing) { + // Why: the recorder owns crash classification and durable suppression + // diagnostics; filtering here made expected-teardown evidence unreachable. opts?.onRendererProcessGone?.(details, rendererWebContentsId) } if (!windowClosing) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7c9f989be5a..ad3c409d0ec 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -316,6 +316,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' import type { CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, CrashReportRecord, CrashReportSubmitArgs, CrashReportSubmitResult, @@ -1397,10 +1398,9 @@ export type PreloadApi = { ) => Promise recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void submit: (args: CrashReportSubmitArgs) => Promise - copyLatestDiagnostics: (args?: { - reportId?: string - notes?: string - }) => Promise<{ ok: true } | { ok: false; error: string }> + copyLatestDiagnostics: ( + args?: CrashReportCopyDiagnosticsArgs + ) => Promise<{ ok: true } | { ok: false; error: string }> } export: ExportApi gh: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 059e2aa4e90..ae6b06ca175 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -212,6 +212,7 @@ import type { } from '../shared/localhost-worktree-labels' import type { CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, CrashReportSubmitArgs, CrashReportSubmitResult, ReactErrorBoundaryReportArgs, @@ -1148,7 +1149,7 @@ const api = { ipcRenderer.send('crashReports:recordBreadcrumb', args), submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), - copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => + copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) }, diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index d80deb581e5..0f263bf5310 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -16,10 +16,18 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { formatCrashReportText, isReactErrorBoundaryReport, + type CrashReportDiagnosticBundle, type CrashReportRecord } from '../../../../shared/crash-reporting' import type { GitHubViewer } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { + CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID, + getCrashReportCopySubmissionFailure, + getCrashReportSubmitFailureNotice, + getCrashReportSubmitWarningNotice +} from './crash-report-submit-notice' +import { useCrashReportCopy } from './use-crash-report-copy' function formatSummary(report: CrashReportRecord): string { if (isReactErrorBoundaryReport(report)) { @@ -88,6 +96,7 @@ export function CrashReportDialogSurface({ () => (report ? formatCrashReportText(report, deferredNotes) : ''), [deferredNotes, report] ) + const copyCrashReportDetails = useCrashReportCopy(report, notes) const clearViewer = useCallback((): void => { viewerRequestIdRef.current += 1 @@ -121,17 +130,25 @@ export function CrashReportDialogSurface({ loadViewerForOpenDialog() }, [clearViewer, loadViewerForOpenDialog, open]) - const handleCopy = async (): Promise => { - const result = await window.api.crashReports.copyLatestDiagnostics( - report ? { reportId: report.id, notes } : { notes } - ) - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - translate('auto.components.crash.report.CrashReportDialog.8b8473c544', 'Crash report copied.') - ) + const showSubmitFailure = ( + error: unknown, + diagnosticBundle?: CrashReportDiagnosticBundle + ): void => { + const failure = { error, ...(diagnosticBundle ? { diagnosticBundle } : {}) } + const notice = getCrashReportSubmitFailureNotice(failure, includeDiagnosticLogs) + const copyFailure = getCrashReportCopySubmissionFailure(failure) + toast.error(notice.title, { + id: CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID, + description: notice.description, + duration: Infinity, + dismissible: true, + action: { + label: notice.actionLabel, + onClick: () => { + void copyCrashReportDetails(copyFailure) + } + } + }) } const dismissReportIfNeeded = async (): Promise => { @@ -164,22 +181,7 @@ export function CrashReportDialogSurface({ githubEmail: null }) if (!result.ok) { - if (result.diagnosticBundle?.status === 'uploaded') { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.b2e36f53a1', - 'Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.', - { value0: result.diagnosticBundle.ticketId } - ) - ) - } else { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.56a3dfa283', - 'Failed to send crash report.' - ) - ) - } + showSubmitFailure(result.error, result.diagnosticBundle) console.error('Failed to submit crash report:', result.error) return } @@ -188,17 +190,21 @@ export function CrashReportDialogSurface({ } onReportChange(result.report) setNotes('') - toast.success( - translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.') - ) + toast.dismiss(CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID) + const warningNotice = getCrashReportSubmitWarningNotice(result, includeDiagnosticLogs) + if (warningNotice) { + toast.warning(warningNotice.title, { description: warningNotice.description }) + } else { + toast.success( + translate( + 'auto.components.crash.report.CrashReportDialog.8e24fe4f75', + 'Crash report sent.' + ) + ) + } onOpenChange(false) } catch (error) { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.56a3dfa283', - 'Failed to send crash report.' - ) - ) + showSubmitFailure(error) console.error('Failed to submit crash report:', error) } finally { if (mountedRef.current) { @@ -304,7 +310,13 @@ export function CrashReportDialogSurface({ - diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts new file mode 100644 index 00000000000..b7b0c0dd34e --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + getCrashReportCopySubmissionFailure, + getCrashReportSubmitFailureNotice, + getCrashReportSubmitWarningNotice +} from './crash-report-submit-notice' + +describe('crash report submit notices', () => { + it('builds an allow-listed copy context with the exact sanitized failure reasons', () => { + expect( + getCrashReportCopySubmissionFailure({ + error: 'request failed at C:\\Users\\alice\\Orca', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'attachment timeout token=super-secret-value' + } + }) + ).toEqual({ + error: 'request failed at [redacted-path]', + diagnosticContext: { + status: 'not_uploaded', + reason: 'attachment timeout token=[redacted]' + } + }) + }) + + it('keeps the returned error and points attached-log failures to the no-log retry', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'status 413' }, true) + + expect(notice).toEqual({ + title: "Crash report wasn't sent", + description: + 'status 413. Uncheck "Attach recent diagnostic logs" and try again, or copy the details.', + actionLabel: 'Copy Details' + }) + }) + + it('uses connection guidance when diagnostic logs were already excluded', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'network unreachable' }, false) + + expect(notice.description).toBe( + 'network unreachable. Check your connection and try again, or copy the details.' + ) + expect(notice.description).not.toContain('Uncheck') + }) + + it('preserves an uploaded diagnostic ticket alongside the core failure', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'status 502', + diagnosticBundle: { + status: 'uploaded', + ticketId: 'ticket-123', + bundleSubmissionId: 'bundle-123', + bytes: 42, + spanCount: 2 + } + }, + true + ) + + expect(notice.description).toContain('status 502.') + expect(notice.description).toContain( + 'Diagnostic ticket ticket-123 was uploaded but not linked.' + ) + }) + + it('shows both failures when the attachment was already omitted', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'fallback host unreachable', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: request timed out after 60 seconds' + } + }, + true + ) + + expect(notice.description).toContain('fallback host unreachable.') + expect(notice.description).toContain( + 'Diagnostic logs were not attached: diagnostic log attachment failed: request timed out after 60 seconds.' + ) + expect(notice.description).toContain('Check your connection') + expect(notice.description).not.toContain('Uncheck') + }) + + it('safely surfaces Error messages and ignores unhelpful rejected values', () => { + expect( + getCrashReportSubmitFailureNotice({ error: new Error('IPC channel closed') }, false) + .description + ).toContain('IPC channel closed.') + expect( + getCrashReportSubmitFailureNotice({ error: { unexpected: true } }, false).description + ).toContain('The crash report request failed before it returned a reason.') + }) + + it('redacts paths and tokens before displaying transport errors', () => { + const token = `ghp_${'a'.repeat(30)}` + const notice = getCrashReportSubmitFailureNotice( + { + error: `request failed at C:\\Users\\alice\\Orca\\crash-reports.json token=${token}` + }, + false + ) + + expect(notice.description).not.toContain('alice') + expect(notice.description).not.toContain(token) + expect(notice.description).toContain('[redacted') + }) + + it('warns only when an opted-in diagnostic bundle was not attached', () => { + const result = { + ok: true as const, + report: null, + diagnosticBundle: { + status: 'not_uploaded' as const, + reason: 'bundle collection failed' + } + } + + expect(getCrashReportSubmitWarningNotice(result, true)).toEqual({ + title: 'Crash report sent without diagnostic logs', + description: 'Diagnostic logs were not attached: bundle collection failed' + }) + expect(getCrashReportSubmitWarningNotice(result, false)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts new file mode 100644 index 00000000000..29824b60416 --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts @@ -0,0 +1,134 @@ +import { translate } from '@/i18n/i18n' +import { + sanitizeCrashReportString, + type CrashReportCopySubmissionFailure, + type CrashReportDiagnosticBundle, + type CrashReportSubmitResult +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID = 'crash-report-submit-failure' + +export type CrashReportSubmitFailureNotice = { + title: string + description: string + actionLabel: string +} + +export type CrashReportSubmitWarningNotice = { + title: string + description: string +} + +type CrashReportSubmitFailureLike = { + error: unknown + diagnosticBundle?: CrashReportDiagnosticBundle +} + +function normalizedFailureMessage(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + const sanitized = sanitizeCrashReportString(message).trim() + return ( + sanitized || + translate( + 'auto.components.crash.report.submit.notice.unknownError', + 'The crash report request failed before it returned a reason.' + ) + ) +} + +function asSentence(message: string): string { + return /[.!?]$/.test(message) ? message : `${message}.` +} + +export function getCrashReportCopySubmissionFailure( + failure: CrashReportSubmitFailureLike +): CrashReportCopySubmissionFailure { + const diagnosticContext = + failure.diagnosticBundle?.status === 'uploaded' + ? { + status: 'uploaded' as const, + ticketId: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) + } + : failure.diagnosticBundle?.status === 'not_uploaded' + ? { + status: 'not_uploaded' as const, + reason: normalizedFailureMessage(failure.diagnosticBundle.reason) + } + : undefined + return { + error: normalizedFailureMessage(failure.error), + ...(diagnosticContext ? { diagnosticContext } : {}) + } +} + +export function getCrashReportSubmitFailureNotice( + failure: CrashReportSubmitFailureLike, + includeDiagnosticLogs: boolean +): CrashReportSubmitFailureNotice { + const ticketDetail = + failure.diagnosticBundle?.status === 'uploaded' + ? translate( + 'auto.components.crash.report.submit.notice.ticketUploaded', + 'Diagnostic ticket {{value0}} was uploaded but not linked.', + { value0: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) } + ) + : null + const omittedDetail = + failure.diagnosticBundle?.status === 'not_uploaded' + ? asSentence( + translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(failure.diagnosticBundle.reason) } + ) + ) + : null + const attachmentAlreadyOmitted = failure.diagnosticBundle?.status === 'not_uploaded' + const recovery = + includeDiagnosticLogs && !attachmentAlreadyOmitted + ? translate( + 'auto.components.crash.report.submit.notice.uncheckDiagnostics', + 'Uncheck "Attach recent diagnostic logs" and try again, or copy the details.' + ) + : translate( + 'auto.components.crash.report.submit.notice.checkConnection', + 'Check your connection and try again, or copy the details.' + ) + + return { + title: translate( + 'auto.components.crash.report.submit.notice.notSent', + "Crash report wasn't sent" + ), + description: [ + asSentence(normalizedFailureMessage(failure.error)), + ticketDetail, + omittedDetail, + recovery + ] + .filter((part): part is string => Boolean(part)) + .join(' '), + actionLabel: translate('auto.components.crash.report.submit.notice.copyDetails', 'Copy Details') + } +} + +export function getCrashReportSubmitWarningNotice( + result: Extract, + includeDiagnosticLogs: boolean +): CrashReportSubmitWarningNotice | null { + if (!includeDiagnosticLogs || result.diagnosticBundle?.status !== 'not_uploaded') { + return null + } + + return { + title: translate( + 'auto.components.crash.report.submit.notice.sentWithoutDiagnostics', + 'Crash report sent without diagnostic logs' + ), + description: translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(result.diagnosticBundle.reason) } + ) + } +} diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx new file mode 100644 index 00000000000..69b010907ac --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' +import { CRASH_REPORT_COPY_FAILURE_TOAST_ID, useCrashReportCopy } from './use-crash-report-copy' + +const copyLatestDiagnostics = vi.fn() + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + success: vi.fn() + } +})) + +function report(id = 'crash-1'): CrashReportRecord { + return { + id, + createdAt: '2026-05-16T01:00:00.000Z', + status: 'pending', + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: 'win32', + osRelease: 'test', + arch: 'x64', + electronVersion: '41', + chromeVersion: '141', + details: {} + } +} + +beforeEach(() => { + vi.clearAllMocks() + copyLatestDiagnostics.mockResolvedValue({ ok: true }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { crashReports: { copyLatestDiagnostics } } + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useCrashReportCopy', () => { + it('uses the latest edited notes from an older failure-toast action', async () => { + const failure: CrashReportCopySubmissionFailure = { + error: 'report request timed out', + diagnosticContext: { status: 'not_uploaded', reason: 'logs timed out' } + } + const { result, rerender } = renderHook(({ notes }) => useCrashReportCopy(report(), notes), { + initialProps: { notes: 'notes at submit' } + }) + const failureToastCopyAction = result.current + + rerender({ notes: 'notes edited while waiting' }) + await act(async () => failureToastCopyAction(failure)) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-1', + notes: 'notes edited while waiting', + submissionFailure: failure + }) + }) + + it("does not combine an older report action with a newer report's notes", async () => { + const { result, rerender } = renderHook( + ({ currentReport, notes }) => useCrashReportCopy(currentReport, notes), + { + initialProps: { + currentReport: report('crash-a'), + notes: 'latest notes for A' + } + } + ) + const reportACopyAction = result.current + + rerender({ currentReport: report('crash-b'), notes: 'private notes for B' }) + await act(async () => reportACopyAction()) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-a', + notes: 'latest notes for A' + }) + expect(JSON.stringify(copyLatestDiagnostics.mock.calls)).not.toContain('private notes for B') + }) + + it('keeps a returned copy failure visible with its safe reason', async () => { + copyLatestDiagnostics.mockResolvedValueOnce({ + ok: false, + error: 'Crash diagnostics are too large to copy safely.' + }) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + description: 'Crash diagnostics are too large to copy safely.', + duration: Infinity, + dismissible: true + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('shows a safe sticky error toast when copy IPC rejects', async () => { + copyLatestDiagnostics.mockRejectedValueOnce( + new Error('renderer destroyed with token=must-not-be-shown') + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + duration: Infinity, + dismissible: true + }) + expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('must-not-be-shown') + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.ts b/src/renderer/src/components/crash-report/use-crash-report-copy.ts new file mode 100644 index 00000000000..b2a604a072c --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.ts @@ -0,0 +1,69 @@ +import { useCallback, useRef } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_COPY_FAILURE_TOAST_ID = 'crash-report-copy-failure' + +function showCopyFailure(description?: string): void { + toast.error( + translate( + 'auto.components.crash.report.copy.copyFailed', + 'Crash report details could not be copied.' + ), + { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + ...(description ? { description } : {}), + duration: Infinity, + dismissible: true + } + ) +} + +export function useCrashReportCopy( + report: CrashReportRecord | null, + notes: string +): (submissionFailure?: CrashReportCopySubmissionFailure) => Promise { + const reportId = report?.id ?? null + const notesRef = useRef({ reportId, value: notes }) + // Why: a submission toast can outlive the render that created it while the + // user edits or changes reports; keep live notes scoped to that report. + if (notesRef.current.reportId === reportId) { + notesRef.current.value = notes + } else { + notesRef.current = { reportId, value: notes } + } + const reportNotes = notesRef.current + + return useCallback( + async (submissionFailure?: CrashReportCopySubmissionFailure): Promise => { + try { + const result = await window.api.crashReports.copyLatestDiagnostics({ + ...(report ? { reportId: report.id } : {}), + notes: reportNotes.value, + ...(submissionFailure ? { submissionFailure } : {}) + }) + if (!result.ok) { + showCopyFailure(result.error) + return + } + toast.dismiss(CRASH_REPORT_COPY_FAILURE_TOAST_ID) + toast.success( + translate( + 'auto.components.crash.report.CrashReportDialog.8b8473c544', + 'Crash report copied.' + ) + ) + } catch (error) { + console.error('Failed to copy crash report details:', error) + // Why: Sonner closes an action toast when clicked, so a sticky generic + // replacement keeps the failure actionable without exposing raw IPC detail. + showCopyFailure() + } + }, + [report, reportNotes] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0a60a2d35c9..b920423d8a4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", "b082f27490": "Attach recent diagnostic logs", "e59f0b9427": "Sends a capped redacted log bundle with the report." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "Crash report details could not be copied." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b8c369477dd..64015250692 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No se capturó ningún informe de fallo automático. Aun así puedes enviar detalles e incluir logs de diagnóstico recientes cuando estén disponibles.", "b082f27490": "Adjuntar logs de diagnóstico recientes", "e59f0b9427": "Envía un paquete limitado de logs redactados junto con el informe." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "No se pudieron copiar los detalles del informe de fallo." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2796e629344..b7b02bf19eb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", "b082f27490": "Attach recent diagnostic logs", "e59f0b9427": "Sends a capped redacted log bundle with the report." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "クラッシュレポートの詳細をコピーできませんでした。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9cd90833df5..5d24f4f3ca9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "자동 크래시 보고서가 캡처되지 않았습니다. 그래도 세부 정보를 보내고, 가능한 경우 최근 진단 로그를 포함할 수 있습니다.", "b082f27490": "최근 진단 로그 첨부", "e59f0b9427": "보고서와 함께 크기가 제한되고 민감 정보가 제거된 로그 묶음을 보냅니다." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "충돌 보고서 세부 정보를 복사하지 못했습니다." } } }, @@ -12734,7 +12749,7 @@ "confirm": { "move": { "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", "action": "프로젝트 이동" }, "copy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ee6099601e6..8ef27b22171 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "没有捕获到自动崩溃报告。你仍然可以发送详情,并在可用时包含最近的诊断日志。", "b082f27490": "附加最近的诊断日志", "e59f0b9427": "随报告发送经过删减且有大小限制的日志包。" + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "无法复制崩溃报告详细信息。" } } }, diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index af9ef812ce5..b65c9a3c095 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -108,6 +108,19 @@ export type CrashReportSubmitResult = diagnosticBundle?: CrashReportDiagnosticBundle } +export type CrashReportCopySubmissionFailure = { + error: string + diagnosticContext?: + | { status: 'uploaded'; ticketId: string } + | { status: 'not_uploaded'; reason: string } +} + +export type CrashReportCopyDiagnosticsArgs = { + reportId?: string + notes?: string + submissionFailure?: CrashReportCopySubmissionFailure +} + const MAX_STRING_DETAIL_LENGTH = 240 const MAX_STACK_DETAIL_LENGTH = 4_000 const MAX_BREADCRUMB_NAME_LENGTH = 80 From 7708f8e7fe5bf445b6112efeade866bff128812e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:05:58 -0700 Subject: [PATCH 2/8] perf: avoid blocking base-ref resolution (#8182) * perf: avoid blocking base-ref resolution * fix(git): bound async default-base probes * fix(git): bound default-base probes during creation * test(git): cover bounded Windows and WSL probes --- .../git/repo-default-base-timeout.test.ts | 46 +++++++++++++++++++ src/main/git/repo.ts | 23 ++++++++-- src/main/ipc/worktree-remote.ts | 9 ++-- src/main/ipc/worktrees-windows.test.ts | 5 ++ src/main/ipc/worktrees.test.ts | 32 ++++++------- src/main/runtime/orca-runtime.test.ts | 31 ++++++++----- src/main/runtime/orca-runtime.ts | 12 ++--- src/main/worktree-create-base-prefetch.ts | 4 +- 8 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/main/git/repo-default-base-timeout.test.ts diff --git a/src/main/git/repo-default-base-timeout.test.ts b/src/main/git/repo-default-base-timeout.test.ts new file mode 100644 index 00000000000..b7c1518bc77 --- /dev/null +++ b/src/main/git/repo-default-base-timeout.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getBaseRefDefault } from './repo' + +describe('getBaseRefDefault async subprocess bounds', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('bounds every local probe and degrades a timeout to no default', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect(getBaseRefDefault('/repo')).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ cwd: '/repo', timeout: 15_000 }) + } + }) + + it('preserves the same timeout when routing probes through WSL', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect( + getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ + cwd: '\\\\wsl.localhost\\Ubuntu\\repo', + timeout: 15_000, + wslDistro: 'Ubuntu' + }) + } + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index f649017cdeb..fa1c23ebf0d 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -15,6 +15,13 @@ type LocalGitExecOptions = { wslDistro?: string } +type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } @@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise hasGitRefViaExec(exec, ref)) } +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + async function getDefaultBaseRefAsync( path: string, options: LocalGitExecOptions = {} ): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, gitExecOptions(path, options)) - ) + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) } /** diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 3f50a5ce056..e0fd8ee8fbc 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -27,7 +27,11 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' +import { + getBranchConflictKind, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from '../git/repo' import { resolveLocalGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' @@ -1999,8 +2003,7 @@ export async function createLocalWorktree( const baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: () => - resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)), + resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), isBaseUsable: async (baseBranchCandidate) => { if (runtime) { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 7c6d0572da1..73d10495b1f 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -10,6 +10,7 @@ const { removeWorktreeMock, resolveLocalGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getBranchConflictKindMock, getPRForBranchMock, @@ -38,6 +39,7 @@ const { removeWorktreeMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), @@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ getDefaultBaseRef: getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getBranchConflictKind: getBranchConflictKindMock })) @@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { removeWorktreeMock.mockReset() resolveLocalGitUsernameMock.mockReset() getDefaultBaseRefMock.mockReset() + resolveDefaultBaseRefWithLocalGitMock.mockReset() resolveDefaultBaseRefViaExecMock.mockReset() getBranchConflictKindMock.mockReset() getPRForBranchMock.mockReset() @@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index ade839b50fb..db5bf3b1a4a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -28,7 +28,8 @@ const { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -77,7 +78,8 @@ const { removeWorktreeMock: vi.fn(), forceDeleteLocalBranchMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), - getDefaultBaseRefMock: vi.fn(), + getBaseRefDefaultMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getDefaultRemoteMock: vi.fn(), getBranchConflictKindMock: vi.fn(), @@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../git/repo', () => ({ - getDefaultBaseRef: getDefaultBaseRefMock, + getBaseRefDefault: getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, getBranchConflictKind: getBranchConflictKindMock @@ -300,7 +303,8 @@ describe('registerWorktreeHandlers', () => { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -401,7 +405,8 @@ describe('registerWorktreeHandlers', () => { ]) store.getAllWorktreeLineage.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') - getDefaultBaseRefMock.mockReturnValue('origin/main') + getBaseRefDefaultMock.mockResolvedValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') getBranchConflictKindMock.mockResolvedValue(null) @@ -525,6 +530,7 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo') expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( '/workspace/repo', 'origin/master' @@ -2050,12 +2056,6 @@ describe('registerWorktreeHandlers', () => { it('routes local worktree creation through the selected WSL project runtime', async () => { mockSelectedWslProjectRuntime() - resolveDefaultBaseRefViaExecMock.mockImplementation( - async (exec: (args: string[]) => Promise<{ stdout: string }>) => { - await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - return 'origin/main' - } - ) listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', @@ -2087,10 +2087,10 @@ describe('registerWorktreeHandlers', () => { false, { wslDistro: 'Ubuntu' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } - ) + expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({ + cwd: '/workspace/repo', + wslDistro: 'Ubuntu' + }) expect(getBranchConflictKindMock).toHaveBeenCalledWith( '/workspace/repo', 'improve-dashboard', @@ -4980,7 +4980,7 @@ describe('registerWorktreeHandlers', () => { // no origin/main, no origin/master, and no local main/master), we must // fail loudly with a message that prompts the user to pick a base // branch, not hand a non-existent ref to `git worktree add`. - resolveDefaultBaseRefViaExecMock.mockResolvedValue(null) + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null) store.getRepo.mockReturnValue({ id: 'repo-1', path: '/workspace/repo', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1f0fb590010..b8d9eee175f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -45,7 +45,7 @@ import { runHook, shouldRunSetupForCreate } from '../hooks' -import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo' +import { getBaseRefDefault, getBranchConflictKind } from '../git/repo' import type { OrchestrationDb } from './orchestration/db' import type { MessagePriority, MessageRow, MessageType } from './orchestration/types' import { @@ -521,16 +521,23 @@ vi.mock('../github/issues', async (importOriginal) => { } }) -// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a -// fallback base branch. Real resolution shells out to `git` against the -// test's fabricated repo path, which has no refs, so we stub it to a -// predictable 'origin/main'. The runtime no longer silently fabricates this -// default, so tests that want the legacy behavior must express it via the mock. +// Why: CLI worktree creation resolves a default against fabricated repo paths +// in these tests, so keep the async resolver deterministic. vi.mock('../git/repo', async (importOriginal) => { const actual = (await importOriginal()) as Record + const actualGetBaseRefDefault = actual.getBaseRefDefault as ( + path: string, + options?: { wslDistro?: string } + ) => Promise return { ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + // Why: fabricated local test repos need a deterministic default, while + // WSL coverage must still exercise the real async Git-options path. + getBaseRefDefault: vi + .fn() + .mockImplementation((path: string, options?: { wslDistro?: string }) => + options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main') + ), getBranchConflictKind: vi.fn().mockResolvedValue(null) } }) @@ -2857,7 +2864,7 @@ describe('OrcaRuntimeService', () => { suggestLocalBaseRefUpdate: true } ) - expect(getDefaultBaseRef).toHaveBeenCalled() + expect(getBaseRefDefault).toHaveBeenCalled() } finally { getReposSpy.mockRestore() gitSpy.mockRestore() @@ -5363,7 +5370,7 @@ describe('OrcaRuntimeService', () => { it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() - vi.mocked(getDefaultBaseRef).mockClear() + vi.mocked(getBaseRefDefault).mockClear() const remoteStore = { ...store, getRepos: () => [ @@ -5399,7 +5406,7 @@ describe('OrcaRuntimeService', () => { } expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') - expect(getDefaultBaseRef).not.toHaveBeenCalled() + expect(getBaseRefDefault).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() }) @@ -5465,7 +5472,7 @@ describe('OrcaRuntimeService', () => { }) expect(asyncGitSpy).toHaveBeenCalledWith( ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - wslGitOptions + { ...wslGitOptions, timeout: 15_000 } ) expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions) expect(asyncGitSpy).toHaveBeenCalledWith( @@ -26473,6 +26480,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(gitSpy).toHaveBeenCalledWith( @@ -27140,6 +27148,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(getBranchConflictKind).toHaveBeenCalledWith( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e183aff463e..d14528cedfa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -600,7 +600,6 @@ import type { } from '../../shared/github-project-types' import { getBaseRefDefault, - getDefaultBaseRef, getDefaultRemote, getBranchConflictKind, isGitRepo, @@ -611,6 +610,7 @@ import { parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, mergeBaseRefSearchResultGroups, @@ -14264,8 +14264,8 @@ export class OrcaRuntimeService { repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => hasLocalWorktreeGitOptions - ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) - : Promise.resolve(getDefaultBaseRef(repo.path)), + ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) + : getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, @@ -14296,10 +14296,8 @@ export class OrcaRuntimeService { } }) if (!baseBranch) { - // Why: getDefaultBaseRef returns null when no suitable ref exists. - // Don't fabricate 'origin/main' — passing it to addWorktree would - // produce an opaque git failure. Surface a clear error so the CLI - // caller can pick an explicit --base ref. + // Why: a null default means no suitable ref exists; fail clearly instead + // of handing Git a fabricated origin/main ref. throw new Error( 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 42add57be66..8653c6866eb 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/types' import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' -import { getDefaultBaseRef } from './git/repo' +import { getBaseRefDefault } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' @@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase( const resolvedBaseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path), + resolveDefaultBaseRef: () => getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( repo.path, From fd6805a29919703d8fd84ac2c966647d2a314297 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:13:15 -0700 Subject: [PATCH 3/8] Fix mobile terminal query reply authority (#8227) * Fix mobile terminal query reply authority * fix(terminal): harden mobile query reply handoffs * fix(terminal): exclude passive mobile query responders * fix(terminal): gate mobile query replies on host capability Older hosts strip terminal.send's inputKind (zod drops unknown keys), so a forwarded xterm reply would land as ordinary floor-taking shell input. Hosts now advertise terminal.query-reply-input.v1 via status.get and mobile drops replies unless the host advertises it (pre-fix behavior). Also documents the bounded desktop-to-mobile handoff double-reply residual. Co-authored-by: Orca * fix(terminal): advance snapshot seq across recovery snapshots The pending-overflow recovery loop trims buffered output against recovery.seq while query replay and boundary strips kept using the initial snapshot seq. Unreachable under today's control flow (no await separates the initial-overflow consume from the loop), but the stale seq would silently drop covered query replies if that ordering ever changes. Track the seq that actually covered the buffered chunks. Co-authored-by: Orca --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 145 ++++++++++++++ docs/reference/terminal-query-authority.md | 39 +++- .../app/h/[hostId]/session/[worktreeId].tsx | 23 +++ mobile/src/session/TerminalPaneView.tsx | 3 + mobile/src/terminal/TerminalWebView.tsx | 7 +- .../mobile-terminal-query-reply.test.ts | 81 ++++++++ .../terminal/mobile-terminal-query-reply.ts | 51 +++++ .../src/terminal/terminal-webview-contract.ts | 1 + mobile/src/terminal/terminal-webview-html.ts | 18 +- .../terminal-webview-query-reply-injected.ts | 44 ++++ ...rminal-webview-query-reply-routing.test.ts | 69 +++++++ .../terminal-webview-query-reply-routing.ts | 14 ++ .../terminal-webview-query-reply.test.ts | 126 ++++++++++++ src/main/runtime/mobile-presence-lock.test.ts | 44 ++++ src/main/runtime/orca-runtime.ts | 25 +++ src/main/runtime/rpc/methods/terminal.ts | 189 ++++++++++++++---- src/main/runtime/rpc/terminal-send.test.ts | 151 ++++++++++++++ .../rpc/terminal-subscribe-buffer.test.ts | 89 +++++++++ .../terminal-pane/pty-connection.test.ts | 11 + .../terminal-pane/pty-connection.ts | 36 ++-- src/shared/protocol-version.ts | 8 +- src/shared/terminal-reply-query-scan.test.ts | 41 ++++ src/shared/terminal-reply-query-scan.ts | 125 ++++++++++++ 23 files changed, 1279 insertions(+), 61 deletions(-) create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.test.ts create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.test.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply.test.ts create mode 100644 src/shared/terminal-reply-query-scan.test.ts create mode 100644 src/shared/terminal-reply-query-scan.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1a2bb248fd6..f7d8172cb5f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2736,6 +2736,151 @@ ], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-query.mobile-view-authority", + "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "mobile-runtime-rpc-contract", + "surfaces": [ + "mobile terminal WebView", + "terminal query authority", + "runtime RPC", + "mobile subscription replay", + "multi-mobile input floor" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8128", + "https://github.com/stablyai/orca/pull/8206" + ], + "invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.", + "oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.", + "commands": [ + "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "mobile/src/terminal/terminal-webview-query-reply-routing.test.ts", + "mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/mobile-presence-lock.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/terminal-reply-query-scan.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "assertions": [ + "snapshot replay emits no terminal-data message before the replay boundary", + "a live query after the boundary emits exactly one terminal-data message", + "a superseded terminal generation cannot regain reply authority", + "xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "only the elected mobile subscriber can write a claimed query reply", + "valid replies skip mobileTookFloor", + "ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "live terminal data and view authority register before asynchronous mobile fit", + "a query received during that await is retained and emitted after the snapshot" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "desktop xterm onData replies are dropped while mobile owns the terminal driver", + "desktop capability-handler replies outside onData honor the same mobile authority lock" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "mobile and runtime deterministic unit gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue." + }, + "promotionCriteria": [ + "Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.", + "Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.", + "Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "knownGaps": [ + "No live iOS or Android WebView run is registered.", + "No real SSH, WSL, daemon, or two-device provider-contract run is registered.", + "Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.", + "A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands." + ], + "demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use." + }, { "id": "terminal-runtime.mobile-stream-budget", "title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded", diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index e05b5768836..bb6b11fea8c 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time): stream (CLI `terminal.read`, automation observers) do not suppress — they also do not answer; that bounded no-reply case matches today's behavior. +Mobile streams preserve that singularity across snapshot startup and multiple +views. The mobile WebView suppresses `onData` during snapshot replay, then +enables replies at an explicit live-output boundary. Clearing the WebView drops +that replay queue and resumes live reply authority immediately. If a live query +arrived while the initial snapshot was being built and its output sequence is +covered by that snapshot, runtime RPC re-emits only the bounded query sequence +after the snapshot; ordinary covered output stays deduplicated. `terminal.send` +accepts the resulting `inputKind: query-reply` only from the earliest active +phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest +is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not +change reply ownership; passive desktop-mode watchers are excluded. Peer phones +are rejected, the next subscriber is promoted on unsubscribe, and desktop +parser and capability handlers stay silent until desktop retakes the driver. +Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile +client never forwards replies to a host that lacks it, because such hosts +strip `inputKind` and would treat the bytes as floor-taking shell input. +Residual: a desktop→mobile driver handoff has a bounded double-reply window +until the async driver-change event reaches the desktop renderer's cached +driver map (`isPtyLocked`). + Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes triggered by option mutations). ## Reply classes -| Class | Queries | Answer source | -| --- | --- | --- | -| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | -| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | -| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | -| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | -| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | -| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | +| Class | Queries | Answer source | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | ### View-attribute bridge @@ -237,7 +257,8 @@ any spawn-window drops). the documented ConPTY DA1 variant and the view-attribute parser handlers the headless core cannot serve. 6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. + subscriber is attached. When multiple desktop/mobile views coexist, the + terminal driver and server-side mobile election keep one reply writer. **Contract amendment** — `terminal-model-view-contract.md` invariant 6 is replaced by: diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 18a393cae28..1b63bf59b7a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -112,6 +112,8 @@ import { import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' +import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, @@ -2424,10 +2426,13 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.clear() }, [connState]) + const hostQueryReplyInputSupportedRef = useRef(false) + useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + hostQueryReplyInputSupportedRef.current = false return } let stale = false @@ -2444,11 +2449,16 @@ export default function SessionScreen() { setAgentSessionHistorySupported( status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true ) + // Why: hosts without this capability strip inputKind from terminal.send, + // so a forwarded xterm reply would become floor-stealing shell input. + hostQueryReplyInputSupportedRef.current = + status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) setAgentSessionHistorySupported(false) + hostQueryReplyInputSupportedRef.current = false } }) return () => { @@ -3531,6 +3541,18 @@ export default function SessionScreen() { [allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput] ) + const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => { + void sendMobileTerminalQueryReply({ + bytes, + client: clientRef.current, + clientId: deviceTokenRef.current, + connected: connStateRef.current === 'connected', + handle, + hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current, + subscribedTerminals: terminalUnsubsRef.current + }) + }, []) + async function handleClearTerminal(target: Terminal) { if (!client) { return @@ -4800,6 +4822,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalQueryReply={handleTerminalQueryReply} onTerminalTap={handleTerminalTap} onFileTap={handleFileTap} onOpenUrl={handleTerminalOpenUrl} diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index beb8118dabf..06174cd0378 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -23,6 +23,7 @@ type TerminalPaneViewProps = { onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalQueryReply: (handle: string, bytes: string) => void onTerminalTap: (handle: string) => void onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void onOpenUrl: (handle: string, url: string) => void @@ -44,6 +45,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -80,6 +82,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)} onTerminalTap={() => onTerminalTap(handle)} onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)} onOpenUrl={(url) => onOpenUrl(handle, url)} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 4b84e44081a..7eded2ef5ba 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,6 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' -import { WebView } from 'react-native-webview' -import type { WebViewMessageEvent } from 'react-native-webview' +import { WebView, type WebViewMessageEvent } from 'react-native-webview' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { @@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' +import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' type Props = TerminalWebViewProps @@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef(function } catch { return } + routeTerminalQueryReply(msg, onTerminalQueryReply) if (msg.type === 'web-ready') { confirmWebReady(true) @@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts new file mode 100644 index 00000000000..1b76a89574d --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply' + +function createClient() { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: '1', + ok: true, + result: { send: { accepted: true, bytesWritten: 6 } }, + _meta: { runtimeId: 'runtime-1' } + }) + } as unknown as Pick +} + +describe('sendMobileTerminalQueryReply', () => { + it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: true, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(true) + + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + }) + + it.each([ + ['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'], + ['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'], + ['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'], + ['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1'] + ])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes, + client, + clientId: 'mobile-1', + connected, + handle, + hostSupportsQueryReplyInput: true, + subscribedTerminals + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + // Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops + // unknown keys) and would treat the reply as floor-taking shell input. + it('does not send to a host that has not advertised query-reply input support', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: false, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/terminal/mobile-terminal-query-reply.ts b/mobile/src/terminal/mobile-terminal-query-reply.ts new file mode 100644 index 00000000000..4dd7ae7a8a5 --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.ts @@ -0,0 +1,51 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +type TerminalSubscriptionRegistry = { + has: (handle: string) => boolean +} + +type MobileTerminalQueryReplyOptions = { + bytes: string + client: Pick | null + clientId: string | null + connected: boolean + handle: string + hostSupportsQueryReplyInput: boolean + subscribedTerminals: TerminalSubscriptionRegistry +} + +export function sendMobileTerminalQueryReply({ + bytes, + client, + clientId, + connected, + handle, + hostSupportsQueryReplyInput, + subscribedTerminals +}: MobileTerminalQueryReplyOptions): Promise { + // Why: every subscribed mobile xterm suppresses main's responder, including + // hidden panes, so ownership follows the subscription rather than focus. + // Hosts without terminal.query-reply-input.v1 strip inputKind and would take + // reply bytes as floor-stealing shell input, so drop (pre-fix behavior). + if ( + !client || + !connected || + !hostSupportsQueryReplyInput || + !subscribedTerminals.has(handle) || + !isTerminalQueryReply(bytes) + ) { + return Promise.resolve(false) + } + + return client + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + inputKind: 'query-reply', + ...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {}) + }) + .then(isTerminalSendRpcAccepted, () => false) +} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 808dece2648..63c155b7a23 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -28,6 +28,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalQueryReply?: (bytes: string) => void onTerminalTap?: () => void // Tap landed on a detected file path; RN resolves + opens it. onFileTap?: (pathText: string, line: number | null, column: number | null) => void diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index fcd5ea9cafa..69b2e6f6b51 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' @@ -215,7 +216,7 @@ window.onerror = function(msg) { var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; + var term = null; ${TERMINAL_QUERY_REPLY_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS} writeQueue.push(normalizeStatusDotPresentation(data)); } + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { resetWriteQueue(); @@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} if (!ready || !term || writesDraining || gen !== terminalGeneration) return; var next = nextQueuedWrite(); if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); var callbacks = afterDrainCallbacks; afterDrainCallbacks = []; for (var i = 0; i < callbacks.length; i++) callbacks[i](); @@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; terminalGeneration++; var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); cancelWebglContextRecovery(); webglAddon = null; ready = false; @@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} fontWeight: '300', fontWeightBold: '500', scrollback: 5000, - disableStdin: true, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, cursorBlink: false, cursorStyle: 'bar', cursorInactiveStyle: 'none', @@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} resetEvictionCounter(); cancelSelect(); attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); requestAnimationFrame(function() { if (gen !== terminalGeneration) return; @@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} write(msg.data); } else if (msg.type === 'clear') { terminalGeneration++; - resetWriteQueue(); + resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. statusDotPendingSelector = false; afterDrainCallbacks = []; writesDraining = false; diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts new file mode 100644 index 00000000000..a4ac34e5809 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-injected.ts @@ -0,0 +1,44 @@ +// Kept as one injectable unit so tests execute the same replay/generation gate +// that the WebView document runs, rather than a TypeScript reimplementation. +export const TERMINAL_QUERY_REPLY_JS = ` + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } +` diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts new file mode 100644 index 00000000000..af53772d481 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, _ref) => + React.createElement('WebView', props) + ) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +let renderer: ReactTestRenderer | null = null + +function postWebViewMessage(payload: Record): void { + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +describe('TerminalWebView query reply routing', () => { + afterEach(() => { + if (renderer) { + act(() => renderer?.unmount()) + renderer = null + } + vi.restoreAllMocks() + }) + + it('routes only complete terminal query replies to native', () => { + const onTerminalQueryReply = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) + }) + + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) + postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) + + expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) + expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.ts new file mode 100644 index 00000000000..cf79fb71b7a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.ts @@ -0,0 +1,14 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' + +export function routeTerminalQueryReply( + message: Record, + onTerminalQueryReply: ((bytes: string) => void) | undefined +): void { + if ( + message.type === 'terminal-data' && + typeof message.bytes === 'string' && + isTerminalQueryReply(message.bytes) + ) { + onTerminalQueryReply?.(message.bytes) + } +} diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts new file mode 100644 index 00000000000..4e8add33f2a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +type QueryReplyGate = { + forward: (data: string) => void + queueBoundary: (generation: number) => void + reset: () => void + resume: () => void + setGeneration: (generation: number) => void +} + +function createQueryReplyGate(notify: (message: unknown) => void): { + gate: QueryReplyGate + queuedBoundaries: Array<() => void> +} { + const queuedBoundaries: Array<() => void> = [] + const factory = new Function( + 'notify', + 'enqueueWriteBoundary', + `var terminalGeneration = 0; + ${TERMINAL_QUERY_REPLY_JS} + return { + forward: forwardTerminalDataReply, + queueBoundary: enqueueTerminalDataReplyBoundary, + reset: resetTerminalDataReplyAuthority, + resume: resumeTerminalDataReplyAuthority, + setGeneration: function(next) { terminalGeneration = next; } + };` + ) as ( + notify: (message: unknown) => void, + enqueueBoundary: (callback: () => void) => void + ) => QueryReplyGate + const gate = factory(notify, (callback) => queuedBoundaries.push(callback)) + return { gate, queuedBoundaries } +} + +describe('mobile terminal query replies', () => { + it('forwards xterm-generated data only after initial replay drains', () => { + const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + listenerIndex + ) + const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'forwardTerminalDataReply(data)', + listenerIndex + ) + + expect(listenerIndex).toBeGreaterThan(-1) + expect(enableIndex).toBeGreaterThan(listenerIndex) + expect(notifyIndex).toBeGreaterThan(listenerIndex) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') + expect(XTERM_WEBVIEW_SOURCE.html).toContain( + 'term.attachCustomKeyEventHandler(function() { return false; })' + ) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + }) + + it('mutes a replacement terminal until its own replay drains', () => { + const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData') + const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'resetTerminalDataReplyAuthority()', + initIndex + ) + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + disableIndex + ) + + expect(initIndex).toBeGreaterThan(-1) + expect(disableIndex).toBeGreaterThan(initIndex) + expect(enableIndex).toBeGreaterThan(disableIndex) + }) + + it('suppresses replay, then forwards live queries queued behind its boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + + queuedBoundaries[0]?.() + gate.forward('\x1b[2;2R') + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }]) + }) + + it('does not let a superseded generation reclaim reply authority', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.queueBoundary(1) + gate.setGeneration(2) + gate.reset() + + queuedBoundaries[0]?.() + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + }) + + it('restores reply authority when clear discards the replay boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + // A clear drops all queued writes, including the replay boundary. + queuedBoundaries.length = 0 + gate.resume() + gate.forward('\x1b[3;4R') + + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) + const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {") + const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf( + "} else if (msg.type === 'measure')", + clearStart + ) + expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain( + 'resumeTerminalDataReplyAuthority()' + ) + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eddb3f299cf..6213765bb20 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + it('elects one query responder and promotes the survivor on unsubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('excludes an older passive desktop-mode subscriber from reply authority', async () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + await vi.advanceTimersByTimeAsync(10) + runtime.markMobileActor('pty-1', 'phone-B') + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + }) + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d14528cedfa..2ef3dfb0e10 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6476,6 +6476,31 @@ export class OrcaRuntimeService { return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 } + isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { + // Why: a passive phone watching desktop-sized output must not race the + // desktop xterm. Mobile becomes reply authority only with the mobile floor. + if (this.getDriver(ptyId).kind !== 'mobile') { + return false + } + const subscribers = this.mobileSubscribers.get(ptyId) + if (!subscribers) { + return false + } + // Why: soft-leave resubscribe preserves the original subscription time but + // reinserts the record. Elect fitted responders from that stable age, not + // mutable Map order or passive desktop-mode watchers. + let earliest: { clientId: string; subscribedAt: number } | null = null + for (const subscriber of subscribers.values()) { + if (!subscriber.wasResizedToPhone) { + continue + } + if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { + earliest = subscriber + } + } + return earliest?.clientId === clientId + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 73c77701c97..339ae8751a9 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -26,6 +26,13 @@ import { } from '../../../../shared/terminal-input' import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQuerySequence, + type TerminalReplyQueryScanState +} from '../../../../shared/terminal-reply-query-scan' import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS @@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 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 TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -372,6 +380,29 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + function appendAckPendingOutput( stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk @@ -699,6 +730,9 @@ const TerminalSend = TerminalHandle.extend({ enter: z.unknown().optional(), interrupt: z.unknown().optional(), requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input bytes but are not a user + // action that should transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), // Why: identifies the caller for the driver state machine. Optional for // backward compatibility with older mobile clients (server falls back to // the most recent mobile actor when absent). New mobile builds populate @@ -983,13 +1017,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } // Why: guarded resolution — a stale handle must fail with // terminal_handle_stale (clients recover by re-deriving the handle) // instead of evaluating driver/lock state against the wrong PTY (#7718). const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { @@ -1096,7 +1157,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (leaf?.ptyId && mobileFloorClientId) { + if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } @@ -2134,6 +2195,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -2236,6 +2301,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ).catch(() => {}) } }) ?? (() => {}) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: live bytes must be captured before mobile fit awaits. Registering + // mobile presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { @@ -2245,36 +2361,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: binary subscribe streams feed remote xterm views (mobile and - // binary-capable desktop clients) that answer queries with view - // authority; the main model responder yields while attached. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { @@ -2305,7 +2391,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq const snapshotFrameSeq = serialized?.seq ?? layoutSeq - const snapshotOutputSeq = serialized?.seq + // Why: recovery snapshots advance output coverage past the initial + // snapshot seq; query replay and boundary trims must track the seq + // that actually covered the buffered chunks or a query absorbed by a + // recovery snapshot gets zero replies. + let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', streamId, @@ -2392,12 +2482,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) pendingOutput = trimmed.chunks pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq } buffering = false const bufferedOutput = pendingOutput.splice(0) + const queryReplayData = pendingQueryOverflowed + ? '' + : pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: serialized snapshots omit control queries, yet their output seq + // can trim the live chunk. Replay only the query after snapshot so the + // mobile xterm answers once while ordinary output stays deduplicated. + outputBatcher.push(queryReplayData) + } if (!initialOutputOverflowed) { for (const item of bufferedOutput) { - const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + pendingQuerySequences + ) + } if (uncoveredData) { outputBatcher.push(uncoveredData, item.meta) } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index c426513c9f5..243003eeec3 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -4,6 +4,10 @@ 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 { + RUNTIME_CAPABILITIES, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR @@ -169,6 +173,148 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') }) + it('writes a validated terminal query reply without taking the mobile floor', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: '\x1b[3;4R', enter: false, interrupt: false }, + { beforeWrite: undefined } + ) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts a query reply from only the elected mobile subscriber', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + isMobileTerminalQueryReplyAuthority: vi.fn( + (_ptyId: string, clientId: string) => clientId === 'mobile-1' + ), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const makeReply = (clientId: string) => + dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: clientId, type: 'mobile' } + }) + ) + const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')]) + + expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } }) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(1) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('rejects ordinary input that claims to be a terminal query reply', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'a', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it.each([ + ['enter', { enter: true }], + ['interrupt', { interrupt: true }], + ['agent guard', { requireAgentStatus: 'sendable' }] + ])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' }, + ...extra + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('rejects query replies that spoof a different authenticated mobile client', async () => { + const replies: string[] = [] + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'spoofed-mobile', type: 'mobile' } + }), + (reply) => replies.push(reply), + { clientId: 'authenticated-mobile' } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('rejects oversized terminal send text before runtime dispatch', async () => { const secret = 'terminal-send-secret' const runtime = stubRuntime({ @@ -435,4 +581,9 @@ describe('terminal send RPC', () => { expect(response.result).toEqual({ restored: true }) expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') }) + // Why: mobile gates reply forwarding on this static capability; removing it + // would silently re-break mobile query answering against current hosts. + it('advertises query-reply input support to mobile clients', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) + }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index ba921c6b14e..06b006a4d0e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => { } }) + it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveMobileSubscribe: () => void = () => {} + const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn()) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve) => { + resolveMobileSubscribe = () => resolve(true) + }) + ), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + 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) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(dataListener).toBeDefined() + expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1') + dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 }) + resolveMobileSubscribe() + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + ).toBe(true) + ) + const queryOutputFrames = binaryFrames.filter((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + expect(queryOutputFrames).toHaveLength(1) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const runtime = stubRuntime({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 18fbf3e1f6a..081b22be937 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4286,10 +4286,21 @@ describe('connectPanePty', () => { } // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') + // Capability handlers are registered outside onData and must honor the + // same mobile query-authority lock. + const csiCalls = ( + pane.terminal.parser.registerCsiHandler as unknown as { + mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] } + } + ).mock.calls + const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1] + expect(da1Handler).toBeTypeOf('function') + da1Handler?.([]) await flushAsyncTicks() expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0578868f48b..9e313fa50bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3254,6 +3254,14 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + const canSendDesktopQueryReply = (): boolean => { + const ptyId = transport.getPtyId() + return !ptyId || !isPtyLocked(ptyId) + } + // Why: parser/capability handlers bypass the ordinary onData guard. Keep + // desktop silent while the elected mobile xterm owns query replies. + const sendDesktopQueryReplyImmediate = (data: string): boolean => + canSendDesktopQueryReply() && transport.sendInputImmediate(data) // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 // responder — visible, hidden, marked or not. Conditioning the reply on the // hidden mark double-fired (mark set + bytes delivered live via interest → @@ -3271,7 +3279,7 @@ export function connectPanePty( ) // Why immediate: a mode-2031 query reply must beat the remote input debounce // or it can miss the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) // Why: register the subscription exactly like the xterm CSI handler // would — without the registry entry, later theme flips never push the // CSI 997 update and the TUI keeps a stale theme after reveal. @@ -3285,13 +3293,13 @@ export function connectPanePty( // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode // read window; the remote transport's input debounce would corrupt them // (#7329), so send immediately. - sendInput: (data) => transport.sendInputImmediate(data), + sendInput: sendDesktopQueryReplyImmediate, isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( pane.terminal, - (data) => transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate ) const onDataDisposable = pane.terminal.onData((data) => { @@ -3339,7 +3347,7 @@ export function connectPanePty( // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), // so a real keystroke never reaches this branch. if (isTerminalQueryReply(data)) { - transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate(data) return } const intent = pendingTerminalInputIntent @@ -5031,7 +5039,7 @@ export function connectPanePty( // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer // immediately so the reply cannot outlive the program's read window. deps.paneMode2031Ref.current.set(pane.id, true) - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) deps.paneLastThemeModeRef.current.set(pane.id, mode) recordHiddenMode2031Reply() } @@ -5202,8 +5210,10 @@ export function connectPanePty( // Why: Codex's startup palette probe has a 100 ms budget. Answer // hidden color queries directly and immediately so neither renderer // scheduling nor the remote input debounce (#7329) can miss it. - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } if (extracted.statelessQueryData) { @@ -5322,8 +5332,10 @@ export function connectPanePty( } const extracted = extractHiddenStartupRendererQueryData(data, '') if (extracted.oscColorQueryData) { - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } let unansweredQueryData = '' @@ -5337,9 +5349,9 @@ export function connectPanePty( const buffer = pane.terminal.buffer.active const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) - transport.sendInputImmediate(`\x1b[${row};${col}R`) + sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { - transport.sendInputImmediate(DEFAULT_DA1_RESPONSE) + sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE) } else { unansweredQueryData += sequence } @@ -6293,7 +6305,7 @@ export function connectPanePty( pane.terminal, // Why: OSC color reply — immediate so the remote debounce cannot delay // it past the querying program's read window (#7329). - (reply) => transport.sendInputImmediate(reply) + sendDesktopQueryReplyImmediate ) } const restoreAppliesToCurrentPty = diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 68a693ab42b..89e4303014c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -38,6 +38,11 @@ export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +// Why: hosts without this strip terminal.send's inputKind (zod object drops +// unknown keys), so a mobile xterm query reply would land as ordinary +// floor-taking input. Mobile must not forward replies unless advertised. +export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = + 'terminal.query-reply-input.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -53,7 +58,8 @@ export const RUNTIME_CAPABILITIES = [ WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, - AI_VAULT_RUNTIME_CAPABILITY + AI_VAULT_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/terminal-reply-query-scan.test.ts b/src/shared/terminal-reply-query-scan.test.ts new file mode 100644 index 00000000000..2a94eaad63d --- /dev/null +++ b/src/shared/terminal-reply-query-scan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences +} from './terminal-reply-query-scan' + +describe('terminal reply query scan', () => { + it('records reply-eliciting queries with their output high-water sequence', () => { + const data = `before\x1b[6nafter\x1b[?2031h` + const result = scanTerminalReplyQuerySequences(data, 100, EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + + expect(result.queries).toEqual([ + { data: '\x1b[6n', startSeq: 106, endSeq: 110 }, + { data: '\x1b[?2031h', startSeq: 115, endSeq: 123 } + ]) + expect(result.state).toEqual(EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + }) + + it('assembles a query split across contiguous PTY chunks', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 23, first.state) + + expect(first.queries).toEqual([]) + expect(second.queries).toEqual([{ data: '\x1b[?2026$p', startSeq: 20, endSeq: 29 }]) + }) + + it('drops a partial query when output sequence continuity is lost', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 30, first.state) + + expect(second.queries).toEqual([]) + }) +}) diff --git a/src/shared/terminal-reply-query-scan.ts b/src/shared/terminal-reply-query-scan.ts new file mode 100644 index 00000000000..434d37041d7 --- /dev/null +++ b/src/shared/terminal-reply-query-scan.ts @@ -0,0 +1,125 @@ +import { findCsiFinalByteIndex } from './terminal-reply-query-extraction' +import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +const ESC = '\x1b' +const MAX_PENDING_QUERY_CHARS = 4096 +/* oxlint-disable no-control-regex -- terminal query grammars contain ESC by definition */ +const DEVICE_ATTRIBUTES_QUERY_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') +const MODE_QUERY_RE = new RegExp('^\\u001b\\[\\??[0-9;]+\\$p$') +/* oxlint-enable no-control-regex */ + +export type TerminalReplyQuerySequence = { + data: string + startSeq: number + endSeq: number +} + +export type TerminalReplyQueryScanState = { + pending: string + pendingStartSeq: number | null +} + +export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState = { + pending: '', + pendingStartSeq: null +} + +function isReplyElicitingCsi(sequence: string): boolean { + if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) { + return true + } + if (MODE_QUERY_RE.test(sequence)) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[6n' || + sequence === '\x1b[?6n' || + sequence === '\x1b[?996n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' || + sequence === '\x1b[18t' || + sequence === '\x1b[?u' || + sequence === '\x1b[?2031h' + ) +} + +function boundedPending(input: string, startIndex: number): string { + return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS) +} + +export function scanTerminalReplyQuerySequences( + data: string, + chunkStartSeq: number, + previous: TerminalReplyQueryScanState +): { queries: TerminalReplyQuerySequence[]; state: TerminalReplyQueryScanState } { + const continuesPending = + previous.pendingStartSeq !== null && + previous.pendingStartSeq + previous.pending.length === chunkStartSeq + const pending = continuesPending ? previous.pending : '' + const input = pending + data + const inputStartSeq = chunkStartSeq - pending.length + const queries: TerminalReplyQuerySequence[] = [] + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf(ESC, offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + + let endIndex = -1 + let matches = false + if (input.startsWith(`${ESC}[`, candidateIndex)) { + endIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (endIndex !== -1) { + matches = isReplyElicitingCsi(input.slice(candidateIndex, endIndex + 1)) + } + } else if (input.startsWith(`${ESC}]`, candidateIndex)) { + const osc = parseTerminalOscColorQuery(input, candidateIndex) + if (osc.kind === 'partial') { + endIndex = -1 + } else if (osc.kind === 'match') { + endIndex = osc.endIndex - 1 + matches = true + } else { + endIndex = candidateIndex + 1 + } + } else if (input.startsWith(`${ESC}P`, candidateIndex)) { + const terminatorIndex = input.indexOf(`${ESC}\\`, candidateIndex + 2) + if (terminatorIndex !== -1) { + endIndex = terminatorIndex + 1 + const body = input.slice(candidateIndex + 2, terminatorIndex) + matches = body.startsWith('$q') || body.startsWith('+q') + } + } else { + endIndex = candidateIndex + } + + if (endIndex === -1) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + if (matches) { + queries.push({ + data: input.slice(candidateIndex, endIndex + 1), + startSeq: inputStartSeq + candidateIndex, + endSeq: inputStartSeq + endIndex + 1 + }) + } + offset = endIndex + 1 + } + + return { queries, state: EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE } +} From cb6a5e4cb888a65694c69c1ac420c0a972dd65f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:20:08 -0700 Subject: [PATCH 4/8] perf(renderer): dedupe concurrent Markdown document scans (#8115) * perf(renderer): dedupe concurrent Markdown document scans * fix(renderer): preserve Markdown refresh freshness * fix(renderer): bound shared Markdown scan joins * fix(renderer): evict abandoned Markdown scan entries Co-authored-by: Orca --------- Co-authored-by: Orca --- .../markdown-document-list-request.test.ts | 176 ++++++++++++++++++ .../editor/markdown-document-list-request.ts | 69 +++++++ .../components/editor/useMarkdownDocuments.ts | 75 ++++---- 3 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-document-list-request.test.ts create mode 100644 src/renderer/src/components/editor/markdown-document-list-request.ts diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts new file mode 100644 index 00000000000..085b8599fb8 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { + getMarkdownDocumentListRequestKey, + requestSharedMarkdownDocumentList +} from './markdown-document-list-request' + +function context(overrides: Partial = {}): RuntimeFileOperationArgs { + return { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'worktree-1', + worktreePath: '/repo', + ...overrides + } +} + +function deferred(): { + promise: Promise + reject: (error: Error) => void + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('shared Markdown document list requests', () => { + it('collapses concurrent pane scans and releases the result after settlement', async () => { + const pending = deferred() + const load = vi.fn(() => pending.promise) + const requests = Array.from({ length: 200 }, () => + requestSharedMarkdownDocumentList(context(), '/repo', {}, load) + ) + + expect(load).toHaveBeenCalledTimes(1) + expect(new Set(requests).size).toBe(1) + + const documents = [{ filePath: '/repo/README.md', relativePath: 'README.md' }] + pending.resolve(documents as MarkdownDocument[]) + await expect(Promise.all(requests)).resolves.toEqual( + Array.from({ length: 200 }, () => documents) + ) + + const nextDocuments = [{ filePath: '/repo/notes.md', relativePath: 'notes.md' }] + load.mockResolvedValueOnce(nextDocuments as MarkdownDocument[]) + await expect(requestSharedMarkdownDocumentList(context(), '/repo', {}, load)).resolves.toBe( + nextDocuments + ) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('starts a fresh scan after a mutation instead of joining an older snapshot', async () => { + const stale = deferred() + const fresh = deferred() + const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) + + const initialRequest = requestSharedMarkdownDocumentList(context(), '/repo', {}, load) + const mutationRefresh = requestSharedMarkdownDocumentList( + context(), + '/repo', + { requireFresh: true }, + load + ) + + expect(load).toHaveBeenCalledTimes(2) + expect(mutationRefresh).not.toBe(initialRequest) + + const freshDocuments = [{ filePath: '/repo/new.md', relativePath: 'new.md' }] + fresh.resolve(freshDocuments as MarkdownDocument[]) + await expect(mutationRefresh).resolves.toBe(freshDocuments) + + const staleDocuments = [{ filePath: '/repo/old.md', relativePath: 'old.md' }] + stale.resolve(staleDocuments as MarkdownDocument[]) + await expect(initialRequest).resolves.toBe(staleDocuments) + }) + + it('allows an ordinary retry after an older scan stops settling', async () => { + const stale = deferred() + const retry = deferred() + const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(retry.promise) + const now = vi.spyOn(performance, 'now').mockReturnValue(1_000) + + try { + const initialRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load) + now.mockReturnValue(31_001) + const retryRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load) + + expect(load).toHaveBeenCalledTimes(2) + expect(retryRequest).not.toBe(initialRequest) + + retry.resolve([]) + stale.resolve([]) + await expect(retryRequest).resolves.toEqual([]) + await expect(initialRequest).resolves.toEqual([]) + } finally { + now.mockRestore() + } + }) + + it('evicts an abandoned scan for a route that is never requested again', async () => { + const abandoned = deferred() + const load = vi.fn().mockReturnValueOnce(abandoned.promise).mockResolvedValueOnce([]) + const now = vi.spyOn(performance, 'now').mockReturnValue(1_000) + + try { + const abandonedRequest = requestSharedMarkdownDocumentList( + context(), + '/abandoned-only', + {}, + load + ) + now.mockReturnValue(31_001) + // A request for a different route sweeps the expired entry so the map + // does not pin the abandoned promise for the renderer's lifetime. + await expect( + requestSharedMarkdownDocumentList(context(), '/other-route', {}, load) + ).resolves.toEqual([]) + + load.mockResolvedValueOnce([]) + await expect( + requestSharedMarkdownDocumentList(context(), '/abandoned-only', {}, load) + ).resolves.toEqual([]) + expect(load).toHaveBeenCalledTimes(3) + + abandoned.resolve([]) + await expect(abandonedRequest).resolves.toEqual([]) + } finally { + now.mockRestore() + } + }) + + it('keeps runtime routes isolated and encodes unusual paths losslessly', () => { + const local = context() + const ssh = context({ connectionId: 'ssh-1' }) + const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } }) + const unusualRoot = '/repo\nwith-newline' + const windowsRoot = 'C:\\repo\\docs' + const uncRoot = '\\\\server\\share\\repo' + + expect(getMarkdownDocumentListRequestKey(local, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(ssh, unusualRoot) + ) + expect(getMarkdownDocumentListRequestKey(ssh, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(runtime, unusualRoot) + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, unusualRoot)).at(-1)).toBe( + unusualRoot + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, windowsRoot)).at(-1)).toBe( + windowsRoot + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, uncRoot)).at(-1)).toBe(uncRoot) + }) + + it('clears rejected requests so a retry can run', async () => { + const failure = deferred() + const load = vi.fn(() => failure.promise) + const first = requestSharedMarkdownDocumentList(context(), '/retry', {}, load) + const second = requestSharedMarkdownDocumentList(context(), '/retry', {}, load) + + failure.reject(new Error('offline')) + await expect(first).rejects.toThrow('offline') + await expect(second).rejects.toThrow('offline') + + load.mockResolvedValueOnce([]) + await expect(requestSharedMarkdownDocumentList(context(), '/retry', {}, load)).resolves.toEqual( + [] + ) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts new file mode 100644 index 00000000000..7e9cc0f32b3 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -0,0 +1,69 @@ +import type { MarkdownDocument } from '../../../../shared/types' +import { + listRuntimeMarkdownDocuments, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' + +type MarkdownDocumentListLoader = ( + context: RuntimeFileOperationArgs, + rootPath: string +) => Promise + +type MarkdownDocumentListRequestOptions = { + requireFresh?: boolean +} + +type InFlightMarkdownDocumentList = { + request: Promise + startedAt: number +} + +const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000 +const inFlightMarkdownDocumentLists = new Map() + +export function getMarkdownDocumentListRequestKey( + context: RuntimeFileOperationArgs, + rootPath: string +): string { + return JSON.stringify([ + context.settings?.activeRuntimeEnvironmentId?.trim() ?? '', + context.connectionId ?? '', + context.worktreeId ?? '', + context.worktreePath ?? '', + rootPath + ]) +} + +export function requestSharedMarkdownDocumentList( + context: RuntimeFileOperationArgs, + rootPath: string, + options: MarkdownDocumentListRequestOptions = {}, + load: MarkdownDocumentListLoader = listRuntimeMarkdownDocuments +): Promise { + const now = performance.now() + // Why: a never-settling scan for a route that is never requested again would + // otherwise pin its map entry (and captured context) for the renderer's life. + for (const [staleKey, entry] of inFlightMarkdownDocumentLists) { + if (now - entry.startedAt >= MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS) { + inFlightMarkdownDocumentLists.delete(staleKey) + } + } + + const key = getMarkdownDocumentListRequestKey(context, rootPath) + const existing = inFlightMarkdownDocumentLists.get(key) + if (existing && !options.requireFresh) { + return existing.request + } + + // Why: split Markdown panes mount together and otherwise launch identical + // whole-worktree local/SSH scans; mutation refreshes bypass older snapshots. + const request = load(context, rootPath).finally(() => { + if (inFlightMarkdownDocumentLists.get(key)?.request === request) { + inFlightMarkdownDocumentLists.delete(key) + } + }) + // Why: local and UNC filesystem calls have no timeout, so an abandoned scan + // must not suppress every ordinary retry for the renderer's lifetime. + inFlightMarkdownDocumentLists.set(key, { request, startedAt: now }) + return request +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 1dd3c22c6fd..ae7fca13de6 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' -import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { statRuntimePath } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { @@ -11,6 +11,7 @@ import { resolveMarkdownDocLink } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' +import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -52,43 +53,47 @@ export function useMarkdownDocuments( const connectionId = getConnectionId(worktreeId) - const refreshMarkdownDocuments = useCallback(async (): Promise => { - if (!worktreeId || !worktreePath) { - return - } - - const requestId = requestRef.current + 1 - requestRef.current = requestId - try { - const documents = await listRuntimeMarkdownDocuments( - { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, - activeFile.runtimeEnvironmentId - ), - worktreeId, - worktreePath, - connectionId: connectionId ?? undefined - }, - worktreePath - ) - if (requestRef.current !== requestId) { + const refreshMarkdownDocuments = useCallback( + async (requireFresh = false): Promise => { + if (!worktreeId || !worktreePath) { return } - setMarkdownDocumentsByWorktree((prev) => ({ - ...prev, - [worktreeId]: documents - })) - } catch (err) { - console.error('Failed to list markdown documents:', err) - if (requestRef.current === requestId) { + + const requestId = requestRef.current + 1 + requestRef.current = requestId + try { + const documents = await requestSharedMarkdownDocumentList( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + worktreePath, + { requireFresh } + ) + if (requestRef.current !== requestId) { + return + } setMarkdownDocumentsByWorktree((prev) => ({ ...prev, - [worktreeId]: [] + [worktreeId]: documents })) + } catch (err) { + console.error('Failed to list markdown documents:', err) + if (requestRef.current === requestId) { + setMarkdownDocumentsByWorktree((prev) => ({ + ...prev, + [worktreeId]: [] + })) + } } - } - }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]) + }, + [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath] + ) const openMarkdownDocument = useCallback( async ( @@ -112,11 +117,11 @@ export function useMarkdownDocuments( document.filePath ) if (stats.isDirectory) { - await refreshMarkdownDocuments() + await refreshMarkdownDocuments(true) return } } catch { - await refreshMarkdownDocuments() + await refreshMarkdownDocuments(true) return } @@ -174,7 +179,7 @@ export function useMarkdownDocuments( ) const mdSave = useCallback( - (content: string) => onSave(content).then(() => refreshMarkdownDocuments()), + (content: string) => onSave(content).then(() => refreshMarkdownDocuments(true)), [onSave, refreshMarkdownDocuments] ) From fb2f25e8f6c92d916a1472ec611d87e4d96485c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:21:28 -0700 Subject: [PATCH 5/8] Update mobile 0.0.27 Android download links (#8257) --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.pt.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 136b04ef090..095b0f486e9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 749d07b5811..f8d168e5f62 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b19a58f5012..6c5e1bbfff5 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 9848afd6ef5..ce9a13cc30b 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 5dd3eee6274..d81c6077879 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 7fe73c4b074..c771506ed7f 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index 1c9e3f41e61..4499e1594cf 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 76c025220df..d077cbea902 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From 674ab4956700b981bf111f75774929560822199c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:57:17 -0700 Subject: [PATCH 6/8] Bound simulator prelaunch handoff cache --- .../src/lib/open-mobile-emulator-tab.test.ts | 5 +- .../lib/simulator-launch-coordination.test.ts | 110 ++++++++++++++++++ .../src/lib/simulator-launch-coordination.ts | 48 +++++++- 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/simulator-launch-coordination.test.ts diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index 100e0532ef7..3def4ca88fb 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -4,7 +4,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { openMobileEmulatorTab } from './open-mobile-emulator-tab' import { consumePrelaunchedSimulatorSession, - isManualSimulatorLaunchPending + isManualSimulatorLaunchPending, + resetSimulatorLaunchCoordinationForTests } from './simulator-launch-coordination' import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler' import { ensureSimulatorTab, getSimulatorTabForWorktree } from './ensure-simulator-tab' @@ -62,7 +63,7 @@ describe('openMobileEmulatorTab', () => { vi.mocked(getSimulatorTabForWorktree).mockReset() vi.mocked(getSimulatorTabForWorktree).mockReturnValue(null) vi.mocked(toast.error).mockReset() - consumePrelaunchedSimulatorSession('wt-1') + resetSimulatorLaunchCoordinationForTests() }) afterEach(() => { diff --git a/src/renderer/src/lib/simulator-launch-coordination.test.ts b/src/renderer/src/lib/simulator-launch-coordination.test.ts new file mode 100644 index 00000000000..a8d70f5ac0c --- /dev/null +++ b/src/renderer/src/lib/simulator-launch-coordination.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EmulatorStreamInfo } from '@/components/emulator-pane/emulator-pane-types' +import { + consumePrelaunchedSimulatorSession, + getPrelaunchedSimulatorSessionCountForTests, + rememberPrelaunchedSimulatorSession, + resetSimulatorLaunchCoordinationForTests +} from './simulator-launch-coordination' + +function streamInfo(id: string): EmulatorStreamInfo { + return { + deviceUdid: id, + streamUrl: `http://127.0.0.1:3100/${id}/stream.mjpeg`, + wsUrl: `ws://127.0.0.1:3100/${id}/ws` + } +} + +describe('simulator-launch-coordination', () => { + beforeEach(() => { + resetSimulatorLaunchCoordinationForTests() + vi.useFakeTimers() + vi.setSystemTime(1_000) + }) + + afterEach(() => { + resetSimulatorLaunchCoordinationForTests() + vi.useRealTimers() + }) + + it('expires unconsumed sessions at the TTL boundary', () => { + rememberPrelaunchedSimulatorSession('wt-fresh', streamInfo('device-fresh')) + rememberPrelaunchedSimulatorSession('wt-stale', streamInfo('device-stale')) + + vi.advanceTimersByTime(29_999) + + expect(consumePrelaunchedSimulatorSession('wt-fresh')).toMatchObject({ + deviceUdid: 'device-fresh' + }) + + vi.advanceTimersByTime(1) + + expect(consumePrelaunchedSimulatorSession('wt-stale')).toBeNull() + expect(getPrelaunchedSimulatorSessionCountForTests()).toBe(0) + }) + + it('measures handoff age independently of wall-clock corrections', () => { + vi.setSystemTime(100_000) + rememberPrelaunchedSimulatorSession('wt-rollback', streamInfo('device-rollback')) + + vi.setSystemTime(0) + vi.advanceTimersByTime(30_000) + + expect(consumePrelaunchedSimulatorSession('wt-rollback')).toBeNull() + + rememberPrelaunchedSimulatorSession('wt-forward', streamInfo('device-forward')) + vi.setSystemTime(1_000_000) + + expect(consumePrelaunchedSimulatorSession('wt-forward')).toMatchObject({ + deviceUdid: 'device-forward' + }) + }) + + it('caps prelaunched simulator sessions by oldest worktree key', () => { + for (let index = 0; index < 16; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + } + + rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16')) + + expect(consumePrelaunchedSimulatorSession('wt-0')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-1')).toMatchObject({ + deviceUdid: 'device-1' + }) + expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({ + deviceUdid: 'device-16' + }) + }) + + it('treats a refreshed worktree key as the most recently remembered', () => { + for (let index = 0; index < 16; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + } + + rememberPrelaunchedSimulatorSession('wt-0', streamInfo('device-refreshed')) + rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16')) + + expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-0')).toMatchObject({ + deviceUdid: 'device-refreshed' + }) + expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({ + deviceUdid: 'device-16' + }) + }) + + it('stays bounded through prolonged unconsumed-session churn', () => { + for (let index = 0; index < 5_000; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + expect(getPrelaunchedSimulatorSessionCountForTests()).toBeLessThanOrEqual(16) + } + + expect(consumePrelaunchedSimulatorSession('wt-4983')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-4984')).toMatchObject({ + deviceUdid: 'device-4984' + }) + expect(consumePrelaunchedSimulatorSession('wt-4999')).toMatchObject({ + deviceUdid: 'device-4999' + }) + }) +}) diff --git a/src/renderer/src/lib/simulator-launch-coordination.ts b/src/renderer/src/lib/simulator-launch-coordination.ts index 7f4d05c0830..db363683385 100644 --- a/src/renderer/src/lib/simulator-launch-coordination.ts +++ b/src/renderer/src/lib/simulator-launch-coordination.ts @@ -4,7 +4,31 @@ export const EMULATOR_MANUAL_LAUNCH_STARTED_EVENT = 'orca:emulator-launch-starte export const EMULATOR_MANUAL_LAUNCH_FAILED_EVENT = 'orca:emulator-launch-failed' const manualLaunchesByWorktree = new Set() -const prelaunchedSessionsByWorktree = new Map() +type PrelaunchedSimulatorSession = { + info: EmulatorStreamInfo + rememberedAt: number +} + +// Why: prelaunch info is a short-lived handoff to the pane; if the pane never +// consumes it, stale stream URLs should not stick to removed worktree ids. +const PRELAUNCHED_SIMULATOR_SESSION_TTL_MS = 30_000 +const PRELAUNCHED_SIMULATOR_SESSION_MAX = 16 +const prelaunchedSessionsByWorktree = new Map() + +function prunePrelaunchedSimulatorSessions(now = performance.now()): void { + for (const [worktreeId, entry] of prelaunchedSessionsByWorktree) { + if (now - entry.rememberedAt >= PRELAUNCHED_SIMULATOR_SESSION_TTL_MS) { + prelaunchedSessionsByWorktree.delete(worktreeId) + } + } + while (prelaunchedSessionsByWorktree.size > PRELAUNCHED_SIMULATOR_SESSION_MAX) { + const oldest = prelaunchedSessionsByWorktree.keys().next().value + if (oldest === undefined) { + return + } + prelaunchedSessionsByWorktree.delete(oldest) + } +} export function beginManualSimulatorLaunch(worktreeId: string): void { manualLaunchesByWorktree.add(worktreeId) @@ -25,15 +49,33 @@ export function rememberPrelaunchedSimulatorSession( if (!info?.streamUrl && !info?.wsUrl) { return } - prelaunchedSessionsByWorktree.set(worktreeId, info) + // Why: the TTL measures elapsed handoff age, so wall-clock corrections must + // not resurrect stale stream metadata or discard a fresh handoff. + const rememberedAt = performance.now() + prelaunchedSessionsByWorktree.delete(worktreeId) + prelaunchedSessionsByWorktree.set(worktreeId, { + info, + rememberedAt + }) + prunePrelaunchedSimulatorSessions(rememberedAt) } export function consumePrelaunchedSimulatorSession(worktreeId: string): EmulatorStreamInfo | null { - const info = prelaunchedSessionsByWorktree.get(worktreeId) ?? null + prunePrelaunchedSimulatorSessions() + const info = prelaunchedSessionsByWorktree.get(worktreeId)?.info ?? null prelaunchedSessionsByWorktree.delete(worktreeId) return info } +export function resetSimulatorLaunchCoordinationForTests(): void { + manualLaunchesByWorktree.clear() + prelaunchedSessionsByWorktree.clear() +} + +export function getPrelaunchedSimulatorSessionCountForTests(): number { + return prelaunchedSessionsByWorktree.size +} + export function dispatchManualSimulatorLaunchStarted(worktreeId: string): void { dispatchManualSimulatorLaunchEvent(EMULATOR_MANUAL_LAUNCH_STARTED_EVENT, { worktreeId }) } From f4a9d33ce0d987fc2772b55cdc9005f76a0c1ace Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 05:27:55 -0400 Subject: [PATCH 7/8] fix(files): keep SSH deletes on the remote path (#8218) * fix(files): keep SSH deletes on the remote path (#8135) * fix(files): preserve explorer operation ownership * fix(files): preserve runtime owner caller scope * test(terminal): de-flake stale daemon launch identity ladder assertion Co-authored-by: Orca * fix(files): fail closed on unresolved operation owner in delete flow - Treat non-local operation owners (ssh, runtime, unresolved) as remote for confirm/error copy, then fail closed when the route is null so an unresolved owner never falls through to local filesystem authorization - Fix test setup to preserve existing window properties when stubbing global window, preventing unrelated globals from being clobbered --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../quick-open-file-list.react.test.tsx | 31 ++ .../src/components/quick-open-file-list.ts | 72 ++++- .../components/right-sidebar/FileExplorer.tsx | 2 + .../file-explorer-directory-listing.ts | 60 ++++ .../file-explorer-name-filter-projection.ts | 18 +- .../file-explorer-operation-owner.ts | 146 +++++++++ ...le-explorer-runtime-owner-boundary.test.ts | 3 +- .../right-sidebar/file-explorer-types.ts | 8 + .../right-sidebar/useFileDeletion.ts | 51 ++- .../right-sidebar/useFileExplorerTree.test.ts | 10 +- .../right-sidebar/useFileExplorerTree.ts | 91 +++--- .../terminal-pane/pty-connection.test.ts | 7 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...e-explorer-delete-owner-provenance.test.ts | 242 ++++++++++++++ .../remote-host-file-delete-repro.test.ts | 294 ++++++++++++++++++ 19 files changed, 965 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts create mode 100644 src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts create mode 100644 src/renderer/src/runtime/remote-host-file-delete-repro.test.ts diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index 4a2305e06e3..8dc6c550344 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -191,4 +191,35 @@ describe('useRuntimeFileListForWorktree', () => { expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) }) + + it('does not restart the scan when unrelated ownership metadata changes', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })], + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + + await renderProbe({ enabled: true, onState: () => {}, worktreeId: workspaceKey }) + await waitForListRuntimeFilesCall() + + await act(async () => { + useAppStore.setState({ + repos: [ + { + id: 'unrelated-repo', + path: '/tmp/unrelated', + displayName: 'Unrelated', + badgeColor: '#000', + addedAt: 0 + } + ] + } as Partial) + }) + await flushEffects() + + expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1) + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 13e3738e226..bb539749353 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -1,18 +1,24 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: quick-open file lists are fetched over local or SSH runtime IPC, so loading/error/results track the request lifecycle. */ import { useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { Worktree } from '../../../shared/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -import { getConnectionIdFromState } from '@/lib/connection-context' import { createBrowserUuid } from '@/lib/browser-uuid' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { useWorktreesForRepo } from '@/store/selectors' +import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types' +import { + getFileExplorerOperationOwnerFromState, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from '@/components/right-sidebar/file-explorer-operation-owner' export type RuntimeFileListState = { files: string[] loading: boolean loadError: string | null + operationOwner?: FileExplorerOperationOwner } export function cleanRuntimeFileListError(error: unknown): string { @@ -106,6 +112,9 @@ export function useRuntimeFileListForWorktree({ const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) const [loadError, setLoadError] = useState(null) + const [listedOperationOwner, setListedOperationOwner] = useState({ + kind: 'unresolved' + }) const lastRequestKeyRef = useRef('') const target = useMemo( @@ -114,9 +123,28 @@ export function useRuntimeFileListForWorktree({ ) const { excludeRequest } = target - const connectionId = useAppStore( - (state) => getConnectionIdFromState(state, worktreeId) ?? undefined + const operationOwnerState = useAppStore( + useShallow((state) => ({ + settings: state.settings, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + })) ) + const operationOwner = useMemo( + () => getFileExplorerOperationOwnerFromState(operationOwnerState, worktreeId), + [operationOwnerState, worktreeId] + ) + const operationOwnerKey = JSON.stringify(operationOwner) + const operationOwnerRef = useRef(operationOwner) + operationOwnerRef.current = operationOwner + const operationRoute = getFileExplorerOperationRoute(operationOwner) + const operationRouteAvailable = operationRoute !== null + const connectionId = operationRoute?.connectionId + const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null const activeTargetStatus = useAppStore((state) => connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined ) @@ -126,19 +154,21 @@ export function useRuntimeFileListForWorktree({ activeTargetStatus === 'reconnecting' const requestKey = useMemo( () => - `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`, - [activeTargetStatus, connectionId, excludeRequest.key, worktreePath] + `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`, + [activeTargetStatus, excludeRequest.key, operationOwnerKey, worktreePath] ) useEffect(() => { if (!enabled) { setLoading(false) + setListedOperationOwner({ kind: 'unresolved' }) return } - if (!target.canList || !worktreeId || !worktreePath) { + if (!target.canList || !worktreeId || !worktreePath || !operationRouteAvailable) { setFiles([]) - setLoadError(null) + setListedOperationOwner({ kind: 'unresolved' }) + setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage()) setLoading(false) return } @@ -154,10 +184,9 @@ export function useRuntimeFileListForWorktree({ const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined const requestToken = createBrowserUuid() + const requestOperationOwner = operationOwnerRef.current const requestContext = { - // Why: Quick Open lists files for the selected workspace. It must - // follow that workspace's owner host, not the globally focused host. - settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), + settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, worktreeId, worktreePath, connectionId @@ -171,6 +200,7 @@ export function useRuntimeFileListForWorktree({ .then((result) => { if (!cancelled) { setFiles(result) + setListedOperationOwner(requestOperationOwner) } }) .catch((error) => { @@ -193,7 +223,23 @@ export function useRuntimeFileListForWorktree({ // 30s timeout ("Could not load files for this workspace"). cancelRuntimeFileList(requestContext, requestToken) } - }, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath]) + }, [ + enabled, + excludeRequest, + connectionId, + operationOwnerKey, + operationRouteAvailable, + requestKey, + runtimeEnvironmentId, + target.canList, + worktreeId, + worktreePath + ]) - return { files, loading: loading || connectionPending, loadError } + return { + files, + loading: loading || connectionPending, + loadError, + operationOwner: listedOperationOwner + } } diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index fb91c21963b..9591084e5b9 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -163,6 +163,7 @@ function FileExplorerFiles(): React.JSX.Element { hasNameFilter ? { query: nameFilterQuery, + operationOwner: nameFilterFiles.operationOwner, relativePaths: nameFilterQueryTooLarge ? [] : nameFilterFiles.loading && nameFilterFiles.files.length === 0 @@ -174,6 +175,7 @@ function FileExplorerFiles(): React.JSX.Element { hasNameFilter, nameFilterFiles.files, nameFilterFiles.loading, + nameFilterFiles.operationOwner, nameFilterQuery, nameFilterQueryTooLarge ] diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts new file mode 100644 index 00000000000..13742226c9a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -0,0 +1,60 @@ +import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { DirEntry } from '../../../../shared/types' +import { readRuntimeDirectory } from '@/runtime/runtime-file-client' +import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' +import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' +import { + getFileExplorerOperationOwner, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from './file-explorer-operation-owner' + +export type FileExplorerDirectoryListing = { + entries: DirEntry[] + operationOwner: FileExplorerOperationOwner +} + +export function fileExplorerEntriesToTreeNodes( + entries: DirEntry[], + dirPath: string, + depth: number, + worktreePath: string | null, + operationOwner: FileExplorerOperationOwner +): TreeNode[] { + return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { + const path = joinPath(dirPath, entry.name) + return { + name: entry.name, + path, + relativePath: worktreePath + ? normalizeRelativePath(path.slice(worktreePath.length + 1)) + : entry.name, + isDirectory: entry.isDirectory, + isSymlink: entry.isSymlink, + depth: depth + 1, + operationOwner + } + }) +} + +export async function readFileExplorerDirectory( + activeWorktreeId: string | null | undefined, + worktreePath: string | null, + dirPath: string +): Promise { + const operationOwner = getFileExplorerOperationOwner(activeWorktreeId) + const route = getFileExplorerOperationRoute(operationOwner) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const entries = await readRuntimeDirectory( + { + settings: route.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: route.connectionId + }, + dirPath + ) + return { entries, operationOwner } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index 551b76bf681..ba057328924 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -1,6 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import type { TreeNode } from './file-explorer-types' +import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { createFileExplorerRowProjectionFromParts, type FileExplorerRowProjection @@ -12,6 +12,7 @@ import { isPathIgnored } from './status-display' export type FileExplorerNameFilterProjectionSource = { query: string relativePaths: readonly string[] | null + operationOwner?: FileExplorerOperationOwner } export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024 @@ -133,14 +134,16 @@ function createSyntheticNode( relativePath: string, name: string, depth: number, - isDirectory: boolean + isDirectory: boolean, + operationOwner: FileExplorerOperationOwner | undefined ): TreeNode { return { name, path: joinPath(worktreePath, relativePath), relativePath, isDirectory, - depth + depth, + operationOwner } } @@ -197,7 +200,14 @@ export function createNameFilteredFileExplorerProjection({ let entry = currentChildren.get(name) if (!entry) { entry = { - node: createSyntheticNode(worktreePath, currentRelativePath, name, index, isDirectory), + node: createSyntheticNode( + worktreePath, + currentRelativePath, + name, + index, + isDirectory, + nameFilter.operationOwner + ), children: new Map() } currentChildren.set(name, entry) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts new file mode 100644 index 00000000000..451d0fd807d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -0,0 +1,146 @@ +import { getConnectionIdFromState } from '@/lib/connection-context' +import { useAppStore } from '@/store' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { translate } from '@/i18n/i18n' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getSettingsForWorktreeRuntimeOwner +} from '@/lib/worktree-runtime-owner' +import type { FileExplorerOperationOwner } from './file-explorer-types' + +export type FileExplorerOperationRoute = { + settings: { activeRuntimeEnvironmentId: string | null } + connectionId?: string +} + +type FileExplorerOwnerState = Pick< + AppState, + | 'settings' + | 'repos' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' +> + +export function getFileExplorerOperationOwnerFromState( + state: FileExplorerOwnerState, + worktreeId: string | null | undefined +): FileExplorerOperationOwner { + const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null + if (worktreeId && parsedWorkspace?.type !== 'folder') { + const exactHostIds = getExactWorktreeHostIds(state, worktreeId) + if (exactHostIds.size > 1) { + return { kind: 'unresolved' } + } + const exactHostId = exactHostIds.values().next().value + if (exactHostId) { + return operationOwnerFromHostId(exactHostId) + } + + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repoHostIds = new Set( + state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId) + ) + if (repoHostIds.size > 1) { + return { kind: 'unresolved' } + } + } + + const connectionId = getConnectionIdFromState(state, worktreeId ?? null) + const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + // Why: global runtime focus is not ownership evidence while SSH/local + // metadata is unresolved; destructive actions must wait for explicit provenance. + if (connectionId === undefined && explicitRuntimeEnvironmentId === null) { + return { kind: 'unresolved' } + } + const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId) + // Why: inferred SSH ownership outranks global runtime focus, but an explicit + // workspace runtime still owns its files. + const runtimeEnvironmentId = + connectionId && explicitRuntimeEnvironmentId === null + ? null + : settings.activeRuntimeEnvironmentId?.trim() + if (runtimeEnvironmentId) { + return { kind: 'runtime', environmentId: runtimeEnvironmentId } + } + if (connectionId === undefined) { + return { kind: 'unresolved' } + } + return connectionId ? { kind: 'ssh', connectionId } : { kind: 'local' } +} + +export function getFileExplorerOperationOwner( + worktreeId: string | null | undefined +): FileExplorerOperationOwner { + return getFileExplorerOperationOwnerFromState(useAppStore.getState(), worktreeId) +} + +export function getFileExplorerOperationRoute( + owner: FileExplorerOperationOwner +): FileExplorerOperationRoute | null { + switch (owner.kind) { + case 'local': + return { settings: { activeRuntimeEnvironmentId: null } } + case 'ssh': + return { + settings: { activeRuntimeEnvironmentId: null }, + connectionId: owner.connectionId + } + case 'runtime': + return { settings: { activeRuntimeEnvironmentId: owner.environmentId } } + case 'unresolved': + return null + } +} + +export function getFileExplorerOwnerUnresolvedMessage(): string { + return translate( + 'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved', + "Couldn't determine which host owns this workspace. Check the connection and try again." + ) +} + +function getExactWorktreeHostIds( + state: Pick, + worktreeId: string +): Set { + const hostIds = new Set() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + return hostIds +} + +function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner { + const parsed = parseExecutionHostId(hostId) + switch (parsed?.kind) { + case 'local': + return { kind: 'local' } + case 'ssh': + return { kind: 'ssh', connectionId: parsed.targetId } + case 'runtime': + return { kind: 'runtime', environmentId: parsed.environmentId } + case undefined: + return { kind: 'unresolved' } + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts index 19c8b2a9bee..b4366a4d3c0 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -15,6 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { 'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts', 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', 'src/renderer/src/components/right-sidebar/useFileDuplicate.ts', + 'src/renderer/src/components/right-sidebar/useFileDeletion.ts', 'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts', 'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts', 'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts', @@ -23,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { const text = source(path) expect(text).toMatch( - /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/ + /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/ ) expect(text).not.toContain('settings: useAppStore.getState().settings') expect(text).not.toContain('const settings = useAppStore.getState().settings') diff --git a/src/renderer/src/components/right-sidebar/file-explorer-types.ts b/src/renderer/src/components/right-sidebar/file-explorer-types.ts index 8027f25cefe..d8a773d3f6c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-types.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-types.ts @@ -1,3 +1,9 @@ +export type FileExplorerOperationOwner = + | { kind: 'local' } + | { kind: 'ssh'; connectionId: string } + | { kind: 'runtime'; environmentId: string } + | { kind: 'unresolved' } + export type TreeNode = { name: string path: string @@ -5,6 +11,8 @@ export type TreeNode = { isDirectory: boolean isSymlink?: boolean depth: number + /** Host snapshot that produced this node. Destructive actions fail closed without it. */ + operationOwner?: FileExplorerOperationOwner } export type DirCache = { diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 26319001620..4d868b7bf1b 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -3,11 +3,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { dirname } from '@/lib/path' -import { getConnectionId } from '@/lib/connection-context' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { findWorktreeById } from '@/store/slices/worktree-helpers' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { TreeNode } from './file-explorer-types' +import { getFileExplorerOperationRoute } from './file-explorer-operation-owner' import { requestEditorFileSave, requestEditorSaveQuiesce @@ -15,7 +14,6 @@ import { import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { deleteRuntimePath, - isRemoteRuntimeFileOperation, readRuntimeFileContent, writeRuntimeFile } from '@/runtime/runtime-file-client' @@ -50,6 +48,10 @@ export function useFileDeletion({ }: UseFileDeletionParams): UseFileDeletionResult { const confirm = useConfirmationDialog() const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete') + const unresolvedDeleteOwnerError = translate( + 'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f', + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) // Why: track in-flight deletes per-path so repeated Del presses on the same // node don't issue duplicate IPC calls; the map is a ref to avoid re-renders. const inFlightRef = useRef>(new Set()) @@ -61,21 +63,28 @@ export function useFileDeletion({ } inFlightRef.current.add(node.path) - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const state = useAppStore.getState() - const worktree = activeWorktreeId - ? findWorktreeById(state.worktreesByRepo, activeWorktreeId) - : null - const fileContext = { - settings: state.settings, - worktreeId: activeWorktreeId, - worktreePath: worktree?.path ?? null, - connectionId - } - const isRemote = - connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path) + const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const } + const operationRoute = getFileExplorerOperationRoute(operationOwner) + // Why: treat every non-local owner (ssh, runtime, unresolved) as remote + // for confirm/error copy, then fail closed below when the route is null + // so an unresolved owner never reaches local filesystem authorization. + const isRemote = operationOwner.kind !== 'local' try { + if (!operationRoute) { + throw new Error(unresolvedDeleteOwnerError) + } + // Why: cached nodes can outlive host hydration changes; preserve the + // listing-time owner so deletion cannot jump to a same-path file elsewhere. + const state = useAppStore.getState() + const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null + const connectionId = operationRoute.connectionId + const fileContext = { + settings: operationRoute.settings, + worktreeId: activeWorktreeId, + worktreePath: worktree?.path ?? null, + connectionId + } // Why: remote deletes bypass OS Trash, and undo cannot recover // directories or unreadable files. if (isRemote) { @@ -206,7 +215,15 @@ export function useFileDeletion({ inFlightRef.current.delete(node.path) } }, - [activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir] + [ + activeWorktreeId, + closeFile, + confirm, + isWindows, + openFiles, + refreshDir, + unresolvedDeleteOwnerError + ] ) const requestDelete = useCallback( diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts index 359a353b6d7..b98e61c825e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts @@ -33,7 +33,7 @@ describe('refreshFileExplorerExpandedDirs', () => { '/repo/src': [entry('index.ts')], '/repo/docs': [entry('guide.md')] } - return entriesByPath[dirPath] ?? [] + return { entries: entriesByPath[dirPath] ?? [], operationOwner: { kind: 'local' as const } } }) const refreshed = await refreshFileExplorerExpandedDirs({ @@ -115,9 +115,9 @@ describe('refreshFileExplorerExpandedDirs', () => { ...prev, '/repo/src': newerSrcCache })) - return [entry('stale.ts')] + return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } } } - return [entry('guide.md')] + return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } } }) const refreshed = await refreshFileExplorerExpandedDirs({ @@ -158,10 +158,10 @@ describe('refreshFileExplorerExpandedDirs', () => { }) const readDirectory = vi.fn(async (dirPath: string) => { if (dirPath === '/repo/src') { - return [entry('stale.ts')] + return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } } } await docsGate - return [entry('guide.md')] + return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } } }) const refreshPromise = refreshFileExplorerExpandedDirs({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index dbbd73a1901..b4dc1167323 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,17 +1,22 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' -import { joinPath, normalizeRelativePath } from '@/lib/path' -import { getConnectionId } from '@/lib/connection-context' -import type { DirEntry } from '../../../../shared/types' -import type { DirCache, TreeNode } from './file-explorer-types' +import type { DirCache } from './file-explorer-types' import { splitPathSegments } from './path-tree' -import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' -import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' +import { statRuntimePath } from '@/runtime/runtime-file-client' import { createFileExplorerDirLoadTracker, type FileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { + getFileExplorerOperationOwner, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from './file-explorer-operation-owner' +import { + fileExplorerEntriesToTreeNodes, + readFileExplorerDirectory, + type FileExplorerDirectoryListing +} from './file-explorer-directory-listing' type UseFileExplorerTreeResult = { dirCache: Record @@ -40,45 +45,7 @@ type RefreshFileExplorerExpandedDirsParams = { worktreePath: string dirLoadTracker: FileExplorerDirLoadTracker setDirCache: Dispatch>> - readDirectory: (dirPath: string) => Promise -} - -function entriesToTreeNodes( - entries: DirEntry[], - dirPath: string, - depth: number, - worktreePath: string | null -): TreeNode[] { - return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { - const path = joinPath(dirPath, entry.name) - return { - name: entry.name, - path, - relativePath: worktreePath - ? normalizeRelativePath(path.slice(worktreePath.length + 1)) - : entry.name, - isDirectory: entry.isDirectory, - isSymlink: entry.isSymlink, - depth: depth + 1 - } - }) -} - -async function readWorktreeDirectory( - activeWorktreeId: string | null | undefined, - worktreePath: string | null, - dirPath: string -): Promise { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - return readRuntimeDirectory( - { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - dirPath - ) + readDirectory: (dirPath: string) => Promise } export async function refreshFileExplorerExpandedDirs({ @@ -115,7 +82,7 @@ export async function refreshFileExplorerExpandedDirs({ uniqueDirs.map(async ({ dirPath, depth }) => { const loadToken = loadTokens.get(dirPath)! try { - const entries = await readDirectory(dirPath) + const listing = await readDirectory(dirPath) if (!dirLoadTracker.isCurrent(loadToken)) { return { current: false as const } } @@ -123,7 +90,13 @@ export async function refreshFileExplorerExpandedDirs({ current: true as const, dirPath, cache: { - children: entriesToTreeNodes(entries, dirPath, depth, worktreePath), + children: fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ), loading: false } } @@ -197,14 +170,20 @@ export function useFileExplorerTree( } })) try { - const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) + const listing = await readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) if (!dirLoadTrackerRef.current.isCurrent(loadToken)) { return false } if (depth === -1) { setRootError(null) } - const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath) + const children = fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ) setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } })) return true } catch (error) { @@ -247,13 +226,17 @@ export function useFileExplorerTree( const statPath = useCallback( async (path: string) => { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const operationOwner = getFileExplorerOperationOwner(activeWorktreeId) + const route = getFileExplorerOperationRoute(operationOwner) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } return statRuntimePath( { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + settings: route.settings, worktreeId: activeWorktreeId, worktreePath, - connectionId + connectionId: route.connectionId }, path ) @@ -286,7 +269,7 @@ export function useFileExplorerTree( worktreePath, dirLoadTracker: dirLoadTrackerRef.current, setDirCache, - readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) + readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) }) }, [activeWorktreeId, expanded, loadDir, worktreePath]) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 081b22be937..f04eb1757f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17714,7 +17714,12 @@ describe('connectPanePty', () => { ) await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3) + // The bounded ladder runs at least its initial read plus two retries; an + // incidental droid reconfirm can add one more, so assert the floor, not an + // exact count (matches the warm-reattach-to-shell sibling test above). + expect( + vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length + ).toBeGreaterThanOrEqual(3) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: null, shellForeground: true diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b920423d8a4..d9e2d1e3bb8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "Couldn't determine which host owns this workspace. Check the connection and try again." + }, "useFileDeletion": { "72691dfebc": "Failed to {{value0}} '{{value1}}'.", "96affe1302": "'{{value0}}' moved to {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructive", "92276aceb7": "Delete", "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", + "8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again." }, "useFileExplorerHandlers": { "32cd9fd991": "Cannot open symlink target" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 64015250692..e30c7b66f9f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "No se pudo determinar qué host posee este espacio de trabajo. Comprueba la conexión e inténtalo de nuevo." + }, "useFileDeletion": { "72691dfebc": "No se pudo {{value0}} '{{value1}}'.", "96affe1302": "'{{value0}}' se movió a {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructivo", "92276aceb7": "Eliminar", "7fb9435c86": "Esto elimina permanentemente el directorio y su contenido en el host remoto. Esto no se puede deshacer.", - "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer." + "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.", + "8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo." }, "useFileExplorerHandlers": { "32cd9fd991": "No se puede abrir el destino del enlace simbólico" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b7b02bf19eb..1651fbb09d2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "このワークスペースを所有しているホストを特定できませんでした。接続を確認して、もう一度お試しください。" + }, "useFileDeletion": { "72691dfebc": "{{value0}} '{{value1}}' に失敗しました。", "96affe1302": "「{{value0}}」は {{value1}} に移動されました", @@ -9857,7 +9860,8 @@ "a76c74f105": "破壊的な", "92276aceb7": "削除", "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", + "8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。" }, "useFileExplorerHandlers": { "32cd9fd991": "シンボリックリンクターゲットを開けません" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5d24f4f3ca9..f5af8bbdb59 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "이 워크스페이스를 소유한 호스트를 확인할 수 없습니다. 연결을 확인한 후 다시 시도하세요." + }, "useFileDeletion": { "72691dfebc": "{{value0}} '{{value1}}'에 실패했습니다.", "96affe1302": "'{{value0}}'이(가) {{value1}}(으)로 이동되었습니다.", @@ -9857,7 +9860,8 @@ "a76c74f105": "파괴적", "92276aceb7": "삭제", "7fb9435c86": "원격 호스트의 디렉터리와 그 안의 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", - "23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다." + "23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요." }, "useFileExplorerHandlers": { "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8ef27b22171..c4eb2fdbe8f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "无法确定哪个主机拥有此工作区。请检查连接后重试。" + }, "useFileDeletion": { "72691dfebc": "无法 {{value0}}“{{value1}}”。", "96affe1302": "“{{value0}}”已移至 {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructive", "92276aceb7": "删除", "7fb9435c86": "这会永久删除远程主机上的目录及其内容。此操作无法撤销。", - "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。" + "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。", + "8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。" }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" diff --git a/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts b/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts new file mode 100644 index 00000000000..75e2bfa6bd5 --- /dev/null +++ b/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../shared/types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useAppStore } from '@/store' +import { useFileDeletion } from '@/components/right-sidebar/useFileDeletion' +import { getFileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-operation-owner' +import type { + FileExplorerOperationOwner, + TreeNode +} from '@/components/right-sidebar/file-explorer-types' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' + +const { confirm, toastError } = vi.hoisted(() => ({ + confirm: vi.fn(), + toastError: vi.fn() +})) +const fsReadFile = vi.fn() +const fsDeletePath = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +vi.mock('@/components/confirmation-dialog', () => ({ useConfirmationDialog: () => confirm })) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Delete' })) +vi.mock('@/components/editor/editor-autosave', () => ({ + requestEditorFileSave: vi.fn().mockResolvedValue(undefined), + requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@/components/right-sidebar/fileExplorerUndoRedo', () => ({ + commitFileExplorerOp: vi.fn() +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +const initialState = useAppStore.getInitialState() +const SSH_ID = 'ssh-target-1' +const FOLDER_ID = 'folder-workspace-1' +const LOCAL_REPO_ID = 'repo-shared' +const LOCAL_WORKTREE_ID = `${LOCAL_REPO_ID}::/tmp/project` +const localNode: TreeNode = { + name: 'index.ts', + path: '/tmp/project/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'local' } +} + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { displayName: overrides.id, badgeColor: '#000', addedAt: 0, ...overrides } +} + +function makeWorktree(hostId: Worktree['hostId']): Worktree { + return { + id: LOCAL_WORKTREE_ID, + repoId: LOCAL_REPO_ID, + path: '/tmp/project', + hostId + } as Worktree +} + +function folderWorkspace(): FolderWorkspace { + return { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'Remote workspace', + folderPath: '/home/user/project', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Remote workspace', + parentPath: '/home/user/project', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function renderDelete(activeWorktreeId: string) { + return renderHook(() => + useFileDeletion({ + activeWorktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) +} + +async function requestDelete( + result: ReturnType['result'], + node: TreeNode, + operationOwner: FileExplorerOperationOwner +): Promise { + await act(async () => { + result.current.requestDelete({ ...node, operationOwner }) + }) +} + +beforeEach(() => { + confirm.mockReset().mockResolvedValue(true) + toastError.mockReset() + fsReadFile.mockReset().mockResolvedValue({ content: 'content', isBinary: false }) + fsDeletePath.mockReset().mockResolvedValue(undefined) + runtimeEnvironmentCall + .mockReset() + .mockImplementation((args: { selector?: string; method: string }) => { + const runtimeId = args.selector ?? 'env-1' + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, runtimeId) ?? { + id: 'rpc-1', + ok: true, + result: null, + _meta: { runtimeId } + } + ) + }) + vi.stubGlobal('window', { + api: { + fs: { readFile: fsReadFile, deletePath: fsDeletePath }, + runtime: { call: vi.fn() }, + runtimeEnvironments: { call: runtimeEnvironmentCall, subscribe: vi.fn() } + } + }) +}) + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() +}) + +describe('file explorer deletion owner provenance', () => { + it('keeps a cached runtime node on its listing host after SSH hydration', async () => { + const workspaceId = folderWorkspaceKey(FOLDER_ID) + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + repos: [], + worktreesByRepo: {} + }) + const listingOwner = getFileExplorerOperationOwner(workspaceId) + expect(listingOwner).toEqual({ kind: 'runtime', environmentId: 'focused-env' }) + useAppStore.setState({ + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/home/user/project', + connectionId: SSH_ID, + projectGroupId: 'group-1' + }) + ] + }) + expect(getFileExplorerOperationOwner(workspaceId)).toEqual({ + kind: 'ssh', + connectionId: SSH_ID + }) + + const { result } = renderDelete(workspaceId) + await requestDelete( + result, + { ...localNode, path: '/home/user/project/src/index.ts' }, + listingOwner + ) + + await vi.waitFor(() => + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'focused-env', method: 'files.delete' }) + ) + ) + expect(fsDeletePath).not.toHaveBeenCalled() + }) + + it('fails closed when an exact worktree ID belongs to multiple hosts', async () => { + useAppStore.setState({ + repos: duplicateHostRepos(), + worktreesByRepo: { + [LOCAL_REPO_ID]: [makeWorktree('local'), makeWorktree(`ssh:${SSH_ID}`)] + } + }) + const owner = getFileExplorerOperationOwner(LOCAL_WORKTREE_ID) + expect(owner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(LOCAL_WORKTREE_ID) + await requestDelete(result, localNode, owner) + + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('keeps an explicit local worktree local when duplicate repo IDs include SSH', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + repos: duplicateHostRepos(), + worktreesByRepo: { [LOCAL_REPO_ID]: [makeWorktree('local')] } + }) + const owner = getFileExplorerOperationOwner(LOCAL_WORKTREE_ID) + expect(owner).toEqual({ kind: 'local' }) + + const { result } = renderDelete(LOCAL_WORKTREE_ID) + await requestDelete(result, localNode, owner) + + await vi.waitFor(() => + expect(fsDeletePath).toHaveBeenCalledWith({ + targetPath: localNode.path, + connectionId: undefined, + recursive: false + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) + +function duplicateHostRepos(): Repo[] { + return [ + makeRepo({ id: LOCAL_REPO_ID, path: '/tmp/project', executionHostId: 'local' }), + makeRepo({ id: LOCAL_REPO_ID, path: '/home/user/project', connectionId: SSH_ID }) + ] +} diff --git a/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts b/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts new file mode 100644 index 00000000000..b29856b4d87 --- /dev/null +++ b/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts @@ -0,0 +1,294 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' +import { useAppStore } from '@/store' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useFileDeletion } from '@/components/right-sidebar/useFileDeletion' +import { getFileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-operation-owner' +import type { TreeNode } from '@/components/right-sidebar/file-explorer-types' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' + +const { confirm, toastError } = vi.hoisted(() => ({ + confirm: vi.fn(), + toastError: vi.fn() +})) +const fsReadFile = vi.fn() +const fsDeletePath = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +vi.mock('@/components/confirmation-dialog', () => ({ + useConfirmationDialog: () => confirm +})) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Delete' })) +vi.mock('@/components/editor/editor-autosave', () => ({ + requestEditorFileSave: vi.fn().mockResolvedValue(undefined), + requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@/components/right-sidebar/fileExplorerUndoRedo', () => ({ + commitFileExplorerOp: vi.fn() +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +const initialState = useAppStore.getInitialState() +const SSH_CONNECTION_ID = 'ssh-target-1' +const REMOTE_PATH = '/home/user/project/src/index.ts' +const REMOTE_REPO_ID = 'repo-ssh' +const REMOTE_WORKTREE_ID = `${REMOTE_REPO_ID}::/home/user/project` +const FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const LOCAL_PATH = '/tmp/project/src/index.ts' + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: FOLDER_WORKSPACE_ID, + projectGroupId: 'group-1', + name: 'Remote workspace', + folderPath: '/home/user/project', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeProjectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'Remote workspace', + parentPath: '/home/user/project', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { + displayName: overrides.id, + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +const remoteFile: TreeNode = { + name: 'index.ts', + path: REMOTE_PATH, + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'ssh', connectionId: SSH_CONNECTION_ID } +} + +const localFile: TreeNode = { + name: 'index.ts', + path: LOCAL_PATH, + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'local' } +} + +function renderDelete(activeWorktreeId: string) { + return renderHook(() => + useFileDeletion({ + activeWorktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) +} + +beforeEach(() => { + confirm.mockReset().mockResolvedValue(true) + fsReadFile.mockReset().mockResolvedValue({ content: 'remote', isBinary: false }) + fsDeletePath.mockReset().mockResolvedValue(undefined) + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentCall.mockImplementation((args: { method: string; selector?: string }) => { + const runtimeId = args.selector ?? 'env-1' + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, runtimeId) ?? { + id: 'rpc-1', + ok: true, + result: null, + _meta: { runtimeId } + } + ) + }) + toastError.mockReset() + vi.stubGlobal('window', { + ...window, + api: { + fs: { readFile: fsReadFile, deletePath: fsDeletePath }, + runtime: { call: vi.fn() }, + runtimeEnvironments: { call: runtimeEnvironmentCall, subscribe: vi.fn() } + } + }) +}) + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() +}) + +describe('issue #8135: deleting a remote SSH folder file', () => { + it('keeps an inferred SSH folder workspace off the focused runtime', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [ + makeRepo({ + id: REMOTE_REPO_ID, + path: '/home/user/project', + connectionId: SSH_CONNECTION_ID, + projectGroupId: 'group-1' + }) + ], + worktreesByRepo: {} + }) + + expect(getFileExplorerOperationOwner(folderWorkspaceKey(FOLDER_WORKSPACE_ID))).toEqual({ + kind: 'ssh', + connectionId: SSH_CONNECTION_ID + }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete(remoteFile) + }) + + await vi.waitFor(() => { + expect(fsDeletePath).toHaveBeenCalledWith({ + targetPath: REMOTE_PATH, + connectionId: SSH_CONNECTION_ID, + recursive: false + }) + }) + expect(toastError).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledTimes(1) + }) + + it('fails closed for an ambiguous folder even when a runtime is focused', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [ + makeRepo({ + id: 'repo-local', + path: '/home/user/project/local', + projectGroupId: 'group-1' + }), + makeRepo({ + id: 'repo-ssh', + path: '/home/user/project', + connectionId: SSH_CONNECTION_ID, + projectGroupId: 'group-1' + }) + ], + worktreesByRepo: {} + }) + const operationOwner = getFileExplorerOperationOwner(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + expect(operationOwner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete({ ...remoteFile, operationOwner }) + }) + + await vi.waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) + }) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + }) + + it('stops while the SSH owner is unresolved instead of deleting locally', async () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: {} + }) + const operationOwner = getFileExplorerOperationOwner(REMOTE_WORKTREE_ID) + expect(operationOwner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(REMOTE_WORKTREE_ID) + + await act(async () => { + result.current.requestDelete({ ...remoteFile, operationOwner }) + }) + + await vi.waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) + }) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + }) + + it('routes a runtime-owned folder workspace with its synthetic worktree path', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace({ folderPath: '/tmp/project' })], + projectGroups: [ + makeProjectGroup({ + parentPath: '/tmp/project', + executionHostId: 'runtime:env-1' + }) + ], + repos: [], + worktreesByRepo: {} + }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete({ + ...localFile, + operationOwner: { kind: 'runtime', environmentId: 'env-1' } + }) + }) + + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { + worktree: `id:${folderWorkspaceKey(FOLDER_WORKSPACE_ID)}`, + relativePath: 'src/index.ts', + recursive: false + }, + timeoutMs: 15_000 + }) + }) + expect(fsDeletePath).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledTimes(1) + }) +}) From dcfa91919fa6b758e02d434a9f30b414ec233252 Mon Sep 17 00:00:00 2001 From: Brandon Bennett <107384180+branben@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:35:01 +0000 Subject: [PATCH 8/8] fix(automations): deliver Hermes prompts via native query (#7862) * fix(automations): deliver prompt to Hermes TUI via process-ready signal Hermes's prompt_toolkit TUI never emits the DECSET 2004 bracketed-paste handshake that the default draft-paste readiness waiter gates on, so the automation prompt was silently dropped and the agent sat idle (terminal opens, nothing entered). Add a 'process-ready' DraftPasteReadySignal that arms the quiet-window on first PTY output and pastes once the TUI settles, and assign it to the hermes agent config. Adds a Hermes unit test covering the no-handshake path. Design for the follow-up PR/issue review loop lives in docs/design/pr-issue-review-loop.md (not committed; gitignored). * fix(automations): skip broken process-name fallback for process-ready CodeRabbit: the process-ready fallback consulted waitForAgentReady, which compares the foreground process basename via isExpectedAgentProcess. Wrapped interpreter launches surface as 'python3 .../hermes', so the check can never confirm readiness and only drops the paste. process-ready readiness is the PTY-quiet window (handled by waitForAgentDraftInputReady); the process-name fallback is dead for this signal. Skip it and return false on timeout instead. * test(automations): assert single paste for Hermes quiet-window path Guard against a duplicate paste in the no-handshake Hermes path by asserting sendRuntimePtyInputVerified is called exactly once. Addresses the CodeRabbit nitpick on PR #7862. * fix(automations): skip broken process-name fallback on pty-bound paste path pasteDraftToAgentPtyWhenReady (quick-create/work-item route) still ran the python3-vs-hermes process-name fallback that can never match for process-ready agents, burning ~1s and dropping the paste. Mirror the process-ready guard from pasteDraftWhenAgentReady and surface onTimeout. Adds regression tests for both the quiet-window happy path and the fallback-skip on the pty-bound path. Co-authored-by: Orca * fix(automations): defer submit Enter until the Hermes TUI is interactive Live-testing against Hermes v0.18.2 showed the fixed 50ms post-paste Enter is swallowed: the node ui-tui takes 15s+ to boot, the paste fires ~1.5s in (process-ready quiet window), and the cooked-mode line discipline turns the early \r into \n, which the editor treats as newline-insert. The prompt parked in the input box and the automation never executed. For process-ready agents, defer the Enter until the TUI signals interactivity (DECSET 2004 enable) or echoes the pasted content marker-free (legacy prompt_toolkit), with a 120s best-effort cap. PTY input is FIFO, so the deferred Enter always lands after the buffered paste text. The paste echo of the content itself (raw or caret-notation markers adjacent) is rejected so a cooked-mode echo can't release the Enter early. Verified end-to-end in the dev app: automation prompt pasted, deferred Enter released on tui-ready, Hermes submitted and ran the turn. Co-authored-by: Orca * fix(automations): use Hermes native startup query Co-authored-by: Orca * fix(automations): preserve quoted Hermes queries on Windows * fix(agents): preserve invalid argument error message Co-authored-by: Orca --------- Co-authored-by: Brandon Bennett Co-authored-by: Jinwoo-H Co-authored-by: Orca Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/daemon/pty-subprocess.test.ts | 8 +- src/main/daemon/pty-subprocess.ts | 6 + src/main/providers/local-pty-provider.test.ts | 11 +- src/main/providers/local-pty-provider.ts | 6 + .../buildSourceControlAgentDeliveryPlan.ts | 1 + .../folder-workspace-composer-submit.test.ts | 25 ++ .../folder-workspace-composer-submit.ts | 16 +- src/renderer/src/hooks/useComposerState.ts | 13 + .../lib/agent-background-session-cleanup.ts | 9 + .../github-work-item-background-request.ts | 9 + .../launch-agent-background-session.test.ts | 62 ++++ .../lib/launch-agent-background-session.ts | 29 +- .../source-control-agent-action-plan.test.ts | 26 ++ .../lib/source-control-agent-action-plan.ts | 14 +- src/shared/agent-draft-platform-limit.ts | 18 ++ src/shared/hermes-startup-query.ts | 203 ++++++++++++ .../hermes-startup-query.windows.test.ts | 52 +++ src/shared/sleeping-agent-launch-config.ts | 15 + src/shared/tui-agent-config.ts | 5 +- src/shared/tui-agent-startup-shell.ts | 67 +++- src/shared/tui-agent-startup.test.ts | 303 +++++++++++++++++- src/shared/tui-agent-startup.ts | 68 ++-- 22 files changed, 908 insertions(+), 58 deletions(-) create mode 100644 src/renderer/src/lib/agent-background-session-cleanup.ts create mode 100644 src/shared/agent-draft-platform-limit.ts create mode 100644 src/shared/hermes-startup-query.ts create mode 100644 src/shared/hermes-startup-query.windows.test.ts create mode 100644 src/shared/sleeping-agent-launch-config.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 908ef9a68b2..ca21dd5e58b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2291,6 +2291,7 @@ describe('createPtySubprocess', () => { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', env: { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_HERMES_STARTUP_QUERY: 'line one\nline two', WSLENV: 'FOO/u' } }) @@ -2317,7 +2318,12 @@ describe('createPtySubprocess', () => { // Why: the daemon inherits optional agent-hook env in development. This // test owns only the terminal handle and Powerlevel10k WSLENV contract. expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( - expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV]) + expect.arrayContaining([ + 'FOO/u', + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 5d36ebe9d69..6a285301406 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -56,6 +56,7 @@ import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -717,6 +718,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 57748417cac..66f68d6e868 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -675,7 +675,8 @@ describe('LocalPtyProvider', () => { await provider.spawn({ cols: 80, rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' } }) } finally { if (savedCodexHome === undefined) { @@ -693,8 +694,12 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') - expect(spawnCall[2].env.WSLENV).toBe( - 'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' + expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( + expect.arrayContaining([ + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 806dfdd5d24..90ccae11ea6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -57,6 +57,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -567,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider { // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) } + if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d9bcf0cd17f..336a3882b3d 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -38,6 +38,7 @@ export function buildSourceControlAgentDeliveryPlan({ detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides, + terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell, platform: launchPlatform, isRemote }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index f31853e6117..f221944a8a4 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -23,6 +23,7 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => { }) import { + buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' @@ -633,3 +634,27 @@ describe('submitFolderWorkspaceCreate', () => { expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() }) }) + +describe('buildFolderWorkspaceLinkedStartupPlan', () => { + it('uses cmd quoting for configured arguments on local Windows', () => { + const plan = buildFolderWorkspaceLinkedStartupPlan({ + agent: 'hermes', + linkedWorkItem: { + provider: 'github', + type: 'issue', + number: 42, + title: 'Restore linked quick-create', + url: 'https://github.com/stablyai/orca/issues/42', + repoId: 'repo-1' + }, + note: '', + agentCmdOverrides: {}, + agentArgs: '--provider "value with space"', + platform: 'win32', + shell: 'cmd', + isRemote: false + }) + + expect(plan?.launchCommand).toBe('hermes --tui "--provider" "value with space"') + }) +}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 8732b166ec5..ee48eac66f2 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -17,6 +17,8 @@ import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' +import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { @@ -44,6 +46,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource runtimeEnvironmentId?: string | null @@ -61,7 +64,7 @@ export function getFolderWorkspaceAgentLaunchPlatform( return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM } -function buildFolderWorkspaceLinkedStartupPlan(args: { +export function buildFolderWorkspaceLinkedStartupPlan(args: { agent: TuiAgent linkedWorkItem: LinkedWorkItemSummary note: string @@ -69,6 +72,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs?: string | null agentEnv?: Record platform: NodeJS.Platform + shell?: AgentStartupShell isRemote: boolean }): AgentStartupPlan | null { const { prompt, draftPrompt } = resolveQuickCreateLinkedWorkItemPrompt( @@ -84,6 +88,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs: args.agentArgs, agentEnv: args.agentEnv, platform: args.platform, + shell: args.shell, isRemote: args.isRemote }) : null @@ -110,6 +115,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs: args.agentArgs, agentEnv: args.agentEnv, platform: args.platform, + shell: args.shell, isRemote: args.isRemote, allowEmptyPromptLaunch: true }) @@ -153,6 +159,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, createFolderWorkspace, @@ -168,6 +175,11 @@ export async function submitFolderWorkspaceCreate({ // Why: an SSH folder group runs the plain `orca` relay shim, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const launchIsRemote = Boolean(projectGroup.connectionId) + const launchShell = resolveLocalWindowsAgentStartupShell({ + platform: launchPlatform, + isRemote: launchIsRemote, + terminalWindowsShell + }) const startupPlan = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ @@ -178,6 +190,7 @@ export async function submitFolderWorkspaceCreate({ agentArgs, agentEnv, platform: launchPlatform, + shell: launchShell, isRemote: launchIsRemote }) : quickAgent @@ -188,6 +201,7 @@ export async function submitFolderWorkspaceCreate({ agentArgs, agentEnv, platform: launchPlatform, + shell: launchShell, isRemote: launchIsRemote, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index d1316b98678..922f1262830 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -22,6 +22,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -780,6 +781,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied to remote launch commands. const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false + const selectedRepoStartupShell = resolveLocalWindowsAgentStartupShell({ + platform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote, + terminalWindowsShell: settings?.terminalWindowsShell + }) const selectedRepoProjectId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null const selectedProjectId = selectedProjectGroup @@ -3344,6 +3350,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, + terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', runtimeEnvironmentId: folderTargetRuntimeEnvironmentId, @@ -3399,6 +3406,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.terminalWindowsShell, telemetrySource ] ) @@ -3598,6 +3606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote }) const shouldSeedInitialAgentStatus = @@ -3769,6 +3778,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepo, selectedRepoAgentLaunchPlatform, selectedRepoIsRemote, + selectedRepoStartupShell, selectedRepoIsGit, selectedRepoRequiresConnection, showProjectRequiredError, @@ -4015,6 +4025,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote }) @@ -4039,6 +4050,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, allowEmptyPromptLaunch: true }) @@ -4214,6 +4226,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepo, selectedRepoAgentLaunchPlatform, selectedRepoIsRemote, + selectedRepoStartupShell, selectedRepoIsGit, selectedRepoSettings, selectedRepoRequiresConnection, diff --git a/src/renderer/src/lib/agent-background-session-cleanup.ts b/src/renderer/src/lib/agent-background-session-cleanup.ts new file mode 100644 index 00000000000..dab1945534b --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-cleanup.ts @@ -0,0 +1,9 @@ +export function runBestEffortAgentBackgroundCleanups(...actions: (() => void)[]): void { + for (const action of actions) { + try { + action() + } catch { + // Preserve the launch/setup error that triggered cleanup. + } + } +} diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index 5b157525053..86cf3c82961 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -25,6 +25,7 @@ import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/tas import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' export type GitHubWorkItemBackgroundStoreSnapshot = { repos: readonly Repo[] @@ -43,6 +44,7 @@ export type GitHubWorkItemBackgroundStoreSnapshot = { | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' + | 'terminalWindowsShell' > > | null @@ -185,6 +187,11 @@ export function buildGitHubWorkItemStartupPlan(args: { // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const isRemote = repoIsRemote(repo) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) const draftLaunchPlan = draftPrompt ? buildAgentDraftLaunchPlan({ agent, @@ -193,6 +200,7 @@ export function buildGitHubWorkItemStartupPlan(args: { agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, + shell, isRemote }) : null @@ -215,6 +223,7 @@ export function buildGitHubWorkItemStartupPlan(args: { agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, + shell, isRemote, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 96f01753116..652fddb9bcf 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -22,6 +22,7 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const mockDispatchEvent = vi.fn() +const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ function expectStablePaneSpawn(): string { @@ -94,6 +95,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-launch-platform', () => ({ + getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo +})) + vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, subscribeToPtyExit: mockSubscribeToPtyExit @@ -108,6 +113,7 @@ describe('launchAgentBackgroundSession', () => { resetRemoteRuntimeTerminalMultiplexersForTests() clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') mockRuntimeEnvironmentTransportCall.mockImplementation( (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) @@ -492,6 +498,62 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('passes Hermes automation prompts through the native startup query', async () => { + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' }) + }) + ) + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + + it('uses the configured cmd shell for Windows Hermes background launches', async () => { + mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + Object.assign(state.settings, { terminalWindowsShell: 'cmd.exe' }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('powershell.exe -NoProfile -EncodedCommand'), + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' }) + }) + ) + }) + + it('forwards Hermes startup queries through SSH command transport', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'remote automation prompt' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + connectionId: 'ssh-1', + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) + }) + ) + }) + it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index ccdbd5fa16d..edb4c6d9f68 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -38,14 +38,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' - -function runBestEffortCleanup(action: () => void): void { - try { - action() - } catch { - // Preserve the launch/setup error that triggered cleanup. - } -} +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs @@ -80,6 +74,11 @@ export async function launchAgentBackgroundSession( // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const isRemote = repo ? repoIsRemote(repo) : false + const startupShell = resolveLocalWindowsAgentStartupShell({ + platform: launchPlatform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -92,6 +91,7 @@ export async function launchAgentBackgroundSession( agentArgs, agentEnv, platform: launchPlatform, + shell: startupShell, isRemote, allowEmptyPromptLaunch: !hasPrompt || isFollowupPath }) @@ -311,12 +311,11 @@ export async function launchAgentBackgroundSession( // Why: terminal creation and stream subscription are separate remote calls. // A failure between them must not strand an invisible runtime terminal. exitHandled = true - runBestEffortCleanup(unsubscribeExit) - runBestEffortCleanup(unsubscribeData) - runBestEffortCleanup(() => eagerPtyBuffer?.dispose()) - runBestEffortCleanup(() => sshStartupDelivery.clear()) - runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId)) - runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey)) + runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) + runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) + runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) + runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId)) + runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) if (ptyId) { try { if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) { @@ -330,7 +329,7 @@ export async function launchAgentBackgroundSession( // Best-effort close; retiring the invalid hidden tab must still proceed. } } - runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false })) + runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false })) throw error } } diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index d88cb3b7f84..a1cb9b6d3ed 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -55,6 +55,32 @@ describe('planSourceControlAgentActionLaunch', () => { expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") }) + it.each([ + { + terminalWindowsShell: 'cmd.exe', + expectedCommand: 'powershell.exe -NoProfile -EncodedCommand' + }, + { + terminalWindowsShell: 'git-bash', + expectedCommand: 'ORCA_HERMES_STARTUP_QUERY' + } + ])( + 'uses $terminalWindowsShell quoting for Hermes source-control prompts', + ({ terminalWindowsShell, expectedCommand }) => { + const result = planSourceControlAgentActionLaunch({ + agent: 'hermes', + commandInput: 'Review the change', + promptDelivery: 'auto-submit', + detectedAgents: ['hermes'], + platform: 'win32', + terminalWindowsShell + }) + + expect(result.ok && result.plan.launchCommand).toContain(expectedCommand) + expect(result.ok && result.plan.env?.ORCA_HERMES_STARTUP_QUERY).toBe('Review the change') + } + ) + it('rejects invalid per-action CLI arguments', () => { expect( planSourceControlAgentActionLaunch({ diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index c323688365a..c865f576ed9 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -9,6 +9,7 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' export type SourceControlLaunchPlanDelivery = | 'argv' @@ -36,6 +37,7 @@ export function planSourceControlAgentActionLaunch(args: { cmdOverrides?: Partial> agentArgs?: string | null platform?: NodeJS.Platform + terminalWindowsShell?: string | null /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean @@ -83,7 +85,12 @@ export function planSourceControlAgentActionLaunch(args: { const cmdOverrides = args.cmdOverrides ?? {} const platform = args.platform ?? CLIENT_PLATFORM const isRemote = args.isRemote ?? false - const shell = platform === 'win32' ? 'powershell' : 'posix' + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: args.terminalWindowsShell + }) ?? (platform === 'win32' ? 'powershell' : 'posix') const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) if (!plannedArgs.ok) { return { ok: false, error: plannedArgs.error } @@ -97,6 +104,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -108,6 +116,7 @@ export function planSourceControlAgentActionLaunch(args: { draft: trimmedInput, cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs }) @@ -130,6 +139,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -142,6 +152,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -153,6 +164,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: trimmedInput, cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: false diff --git a/src/shared/agent-draft-platform-limit.ts b/src/shared/agent-draft-platform-limit.ts new file mode 100644 index 00000000000..7b94b5624e3 --- /dev/null +++ b/src/shared/agent-draft-platform-limit.ts @@ -0,0 +1,18 @@ +const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 + +export function inlineAgentDraftFitsPlatform(args: { + command: string + env?: Record + platform: NodeJS.Platform +}): boolean { + if (args.platform !== 'win32') { + return true + } + const envChars = Object.entries(args.env ?? {}).reduce( + (total, [key, value]) => total + key.length + value.length, + 0 + ) + // Why: Windows CreateProcess/env blocks have tight length ceilings. Large + // generated drafts should use the existing post-ready paste fallback. + return args.command.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS +} diff --git a/src/shared/hermes-startup-query.ts b/src/shared/hermes-startup-query.ts new file mode 100644 index 00000000000..79dd818f172 --- /dev/null +++ b/src/shared/hermes-startup-query.ts @@ -0,0 +1,203 @@ +import { + buildShellCommandFromArgv, + quoteStartupArg, + tokenizeStartupCommand, + type AgentStartupShell +} from './tui-agent-startup-shell' + +const QUERY_ENV_LIMIT = 24_000 +const QUERY_PLACEHOLDER = '__ORCA_HERMES_STARTUP_QUERY__' +const QUERY_ARG_PLACEHOLDER = `--query=${QUERY_PLACEHOLDER}` +const POSIX_QUERY_VARIABLE = '__orca_hermes_startup_query' +const POWERSHELL_QUERY_VARIABLE = 'orcaHermesStartupQuery' +const POWERSHELL_NATIVE_QUERY_VARIABLE = 'orcaHermesNativeQuery' + +export const ORCA_HERMES_STARTUP_QUERY_ENV = 'ORCA_HERMES_STARTUP_QUERY' + +function encodePowerShellCommand(command: string): string { + let bytes = '' + for (let index = 0; index < command.length; index += 1) { + const code = command.charCodeAt(index) + bytes += String.fromCharCode(code & 0xff, code >>> 8) + } + return btoa(bytes) +} + +function encodePosixEvalScript(command: string): string { + return Array.from( + new TextEncoder().encode(command), + (byte) => `\\0${byte.toString(8).padStart(3, '0')}` + ).join('') +} + +function tokenizeCommand(value: string, shell: AgentStartupShell): string[] | null { + const tokenized = tokenizeStartupCommand(value, shell) + return tokenized.ok && tokenized.tokens.length > 0 ? tokenized.tokens : null +} + +function isHermesExecutableToken(value: string): boolean { + const basename = value.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + return basename === 'hermes' || basename === 'hermes.exe' || basename === 'hermes.cmd' +} + +const HERMES_VALUELESS_FLAGS = new Set([ + '--tui', + '--cli', + '--verbose', + '-v', + '--quiet', + '-Q', + '--worktree', + '-w', + '--accept-hooks', + '--checkpoints', + '--yolo', + '--pass-session-id', + '--ignore-user-config', + '--ignore-rules', + '--safe-mode', + '--dev' +]) + +function findChatSubcommand(args: readonly string[]): number { + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === 'chat') { + return index + } + if (token.startsWith('-') && !token.includes('=') && !HERMES_VALUELESS_FLAGS.has(token)) { + index += 1 + } + } + return -1 +} + +function stripOrcaOwnedHermesArgs(args: readonly string[]): string[] { + const normalized: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === '--query' || token === '-q') { + index += 1 + continue + } + if ( + token.startsWith('--query=') || + token.startsWith('-q=') || + (token.startsWith('-q') && token.length > 2) || + token === '--tui' || + token === '--cli' + ) { + continue + } + normalized.push(token) + } + return normalized +} + +function normalizeHermesArgv( + baseArgv: string[], + configuredArgv: string[], + shell: AgentStartupShell +): string[] | null { + const executableCandidates: number[] = [] + for (let index = 0; index < baseArgv.length; index += 1) { + if (isHermesExecutableToken(baseArgv[index])) { + executableCandidates.push(index) + } + } + let anchoredExecutable: number | undefined + for (const index of executableCandidates) { + if (findChatSubcommand(baseArgv.slice(index + 1)) !== -1) { + // Wrapper arguments can also be named "hermes"; the final match is the executable. + anchoredExecutable = index + } + } + const executableIndex = anchoredExecutable ?? executableCandidates.at(-1) ?? -1 + if (executableIndex === -1) { + return null + } + let commandPrefix = baseArgv.slice(0, executableIndex + 1) + let assignmentCount = 0 + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(commandPrefix[assignmentCount] ?? '')) { + assignmentCount += 1 + } + if (assignmentCount > 0) { + if (shell !== 'posix') { + return null + } + commandPrefix = ['env', ...commandPrefix] + } + const baseArgs = baseArgv.slice(executableIndex + 1) + const chatIndex = findChatSubcommand(baseArgs) + const baseArgsWithoutChat = + chatIndex === -1 ? baseArgs : baseArgs.filter((_, index) => index !== chatIndex) + const configuredChatIndex = findChatSubcommand(configuredArgv) + const configuredArgsWithoutChat = + configuredChatIndex === -1 + ? configuredArgv + : configuredArgv.filter((_, index) => index !== configuredChatIndex) + return [ + ...commandPrefix, + 'chat', + QUERY_ARG_PLACEHOLDER, + ...stripOrcaOwnedHermesArgs(baseArgsWithoutChat), + ...stripOrcaOwnedHermesArgs(configuredArgsWithoutChat), + '--tui' + ] +} + +function buildQueryCommand(argv: string[], shell: AgentStartupShell): string { + if (shell !== 'posix') { + const invocation = buildShellCommandFromArgv(argv, 'powershell').replace( + quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'powershell'), + `"--query=$${POWERSHELL_NATIVE_QUERY_VARIABLE}"` + ) + // Why: startup prompts must not remain exported to Hermes tools; the + // long-lived parent shell retains the transport env for compatibility. + // PowerShell 5 needs Windows-native quote escaping before building child argv. + const script = `$${POWERSHELL_QUERY_VARIABLE} = $env:${ORCA_HERMES_STARTUP_QUERY_ENV}; $${POWERSHELL_NATIVE_QUERY_VARIABLE} = $${POWERSHELL_QUERY_VARIABLE} -replace '(\\\\*)"', '$1$1\\"'; Remove-Item Env:${ORCA_HERMES_STARTUP_QUERY_ENV} -ErrorAction SilentlyContinue; ${invocation}` + return `powershell.exe -NoProfile -EncodedCommand ${encodePowerShellCommand(script)}` + } + const invocation = buildShellCommandFromArgv(argv, 'posix').replace( + quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'posix'), + `"--query=\${${POSIX_QUERY_VARIABLE}}"` + ) + const encodedInvocation = encodePosixEvalScript(invocation) + // Why: a fixed single-quote-safe wrapper parses in POSIX shells and pwsh; + // the dynamic argv is decoded only after entering the known `sh` grammar. + const script = `${POSIX_QUERY_VARIABLE}="\${${ORCA_HERMES_STARTUP_QUERY_ENV}}"; unset ${ORCA_HERMES_STARTUP_QUERY_ENV}; eval "$(printf %b "${encodedInvocation}")"` + return `sh -c ${quoteStartupArg(script, 'posix')}` +} + +export function planHermesStartupQuery(args: { + baseCommand: string + agentArgs?: string | null + prompt: string + agentEnv?: Record | null + platform: NodeJS.Platform + shell: AgentStartupShell + isRemote?: boolean +}): { command: string; env: Record } | null { + const baseArgv = tokenizeCommand(args.baseCommand, args.shell) + const configuredArgv = args.agentArgs?.trim() ? tokenizeCommand(args.agentArgs, args.shell) : [] + if (!baseArgv || !configuredArgv) { + return null + } + const argv = normalizeHermesArgv(baseArgv, configuredArgv, args.shell) + if (!argv) { + return null + } + const command = buildQueryCommand(argv, args.shell) + const env = { ...args.agentEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]: args.prompt } + const envSize = Object.entries(env).reduce((total, [key, value]) => { + if (args.platform === 'win32') { + return total + key.length + value.length + 2 + } + return total + new TextEncoder().encode(`${key}=${value}`).byteLength + 1 + }, 0) + const commandSize = + args.platform === 'win32' ? command.length : new TextEncoder().encode(command).byteLength + // Why: WSL plans execute as Linux but cross the Windows environment block; + // the conservative shared bound is safe for every transport host. + return commandSize + envSize <= QUERY_ENV_LIMIT ? { command, env } : null +} diff --git a/src/shared/hermes-startup-query.windows.test.ts b/src/shared/hermes-startup-query.windows.test.ts new file mode 100644 index 00000000000..fd0a007cf85 --- /dev/null +++ b/src/shared/hermes-startup-query.windows.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { planHermesStartupQuery } from './hermes-startup-query' + +const windowsIt = process.platform === 'win32' ? it : it.skip + +windowsIt('preserves the startup query and spaced custom args in native Windows argv', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-hermes-startup-query-')) + const capturePath = join(tempDir, 'argv.json') + const hermesPath = join(tempDir, 'hermes.exe') + const prompt = [ + 'Return the marker with quoted="alpha beta" and unicode=雪🚀.', + 'Keep %PATH% literal.', + '--version' + ].join('\n') + + try { + // Why: the Node executable is a real native Windows process, so this catches + // PowerShell argument marshalling bugs that generated-script assertions miss. + copyFileSync(process.execPath, hermesPath) + writeFileSync( + join(tempDir, 'chat'), + `require('node:fs').writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)))` + ) + const plan = planHermesStartupQuery({ + baseCommand: `"${hermesPath}"`, + agentArgs: '--yolo --source "Orca automation validation"', + prompt, + platform: 'win32', + shell: 'powershell' + }) + const encodedCommand = plan?.command.match(/-EncodedCommand\s+(\S+)/)?.[1] + + expect(encodedCommand).toBeDefined() + execFileSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodedCommand!], { + cwd: tempDir, + env: { ...process.env, ...plan?.env } + }) + expect(JSON.parse(readFileSync(capturePath, 'utf8'))).toEqual([ + `--query=${prompt}`, + '--yolo', + '--source', + 'Orca automation validation', + '--tui' + ]) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) diff --git a/src/shared/sleeping-agent-launch-config.ts b/src/shared/sleeping-agent-launch-config.ts new file mode 100644 index 00000000000..b03f6bba961 --- /dev/null +++ b/src/shared/sleeping-agent-launch-config.ts @@ -0,0 +1,15 @@ +import type { SleepingAgentLaunchConfig } from './agent-session-resume' + +export function buildSleepingAgentLaunchConfig(args: { + agentCommand?: string | null + agentArgs?: string | null + agentEnv?: Record | null +}): SleepingAgentLaunchConfig { + return { + ...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}), + agentArgs: args.agentArgs ?? '', + // Why: startup env may include prompt transport or pane identity values; + // durable resume state is limited to Orca-managed agent inputs. + agentEnv: args.agentEnv ? { ...args.agentEnv } : {} + } +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 29a03d14bc4..6e5f5d8295a 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -6,6 +6,7 @@ export type AgentPromptInjectionMode = | 'flag-prompt' | 'flag-prompt-interactive' | 'flag-interactive' + | 'hermes-query' | 'stdin-after-start' export type DraftPasteReadySignal = @@ -325,7 +326,9 @@ export const TUI_AGENT_CONFIG: Record = { // `--tui` starts the full-screen agent UI Orca is designed to host. launchCmd: 'hermes --tui', expectedProcess: 'hermes', - promptInjectionMode: 'stdin-after-start' + // Why: Hermes owns prompt delivery through its startup-query contract, + // which submits only after the TUI composer and session are ready. + promptInjectionMode: 'hermes-query' }, openclaw: { detectCmd: 'openclaw', diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index db1cee17017..8bdb5465845 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -2,6 +2,71 @@ import { tokenizeCustomCommandTemplate } from './commit-message-prompt' export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' +export type StartupCommandTokens = { ok: true; tokens: string[] } | { ok: false; error: string } + +function tokenizeWindowsStartupCommand( + value: string, + shell: Exclude +): StartupCommandTokens { + const tokens: string[] = [] + let token = '' + let quote: "'" | '"' | null = null + let tokenStarted = false + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + const escape = shell === 'cmd' ? '^' : '`' + if (char === escape && index + 1 < value.length) { + token += value[index + 1] + tokenStarted = true + index += 1 + continue + } + if (quote) { + if (char === quote) { + if (shell === 'powershell' && quote === "'" && value[index + 1] === "'") { + token += "'" + index += 1 + } else { + quote = null + } + } else { + token += char + } + tokenStarted = true + continue + } + if (char === "'" || char === '"') { + quote = char + tokenStarted = true + } else if (/\s/.test(char)) { + if (tokenStarted) { + tokens.push(token) + token = '' + tokenStarted = false + } + } else { + token += char + tokenStarted = true + } + } + if (quote) { + return { ok: false, error: 'Unclosed quote in command template.' } + } + if (tokenStarted) { + tokens.push(token) + } + return { ok: true, tokens } +} + +export function tokenizeStartupCommand( + value: string, + shell: AgentStartupShell +): StartupCommandTokens { + return shell === 'posix' + ? tokenizeCustomCommandTemplate(value) + : tokenizeWindowsStartupCommand(value, shell) +} + export function resolveStartupShell( platform: NodeJS.Platform, shell?: AgentStartupShell @@ -54,7 +119,7 @@ export function planAgentCliArgsSuffix( if (!trimmed) { return { ok: true, suffix: '' } } - const tokenized = tokenizeCustomCommandTemplate(trimmed) + const tokenized = tokenizeStartupCommand(trimmed, shell) if (!tokenized.ok) { return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` } } diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 71f455fbc57..22ffdcdc15f 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -3,12 +3,42 @@ import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, buildAgentStartupPlan, - buildShellCommandFromArgv + buildShellCommandFromArgv, + planAgentCliArgsSuffix } from './tui-agent-startup' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +function unwrapPosixShellScript(command: string | undefined): string { + const tokenized = tokenizeStartupCommand(command ?? '', 'posix') + expect(tokenized.ok).toBe(true) + const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' + const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] + if (!encoded) { + return wrapper + } + const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +function unwrapPowerShellScript(command: string | undefined): string { + const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} describe('tui agent startup plans', () => { + it.each(['powershell', 'cmd'] as const)( + 'keeps the established invalid-quote error on %s', + (shell) => { + expect(planAgentCliArgsSuffix('--model "unterminated', shell)).toEqual({ + ok: false, + error: 'CLI arguments are invalid: Unclosed quote in command template.' + }) + } + ) + it('uses POSIX quoting when the target shell is Linux', () => { const plan = buildAgentStartupPlan({ agent: 'claude', @@ -106,6 +136,277 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("codex '--version'") }) + it.each([ + { platform: 'linux' as const, shell: 'posix' as const }, + { platform: 'win32' as const, shell: 'powershell' as const }, + { platform: 'win32' as const, shell: 'cmd' as const } + ])('delivers multiline Hermes queries through a child-only expansion on $shell', (testCase) => { + const prompt = 'first line\nsecond "quoted" line with %PATH%' + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: testCase.platform, + shell: testCase.shell + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.followupPrompt).toBeNull() + expect(plan?.launchConfig.agentCommand).toBe('hermes --tui') + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + const script = + testCase.shell === 'posix' + ? unwrapPosixShellScript(plan?.launchCommand) + : unwrapPowerShellScript(plan?.launchCommand) + expect(script).toContain("'hermes' 'chat'") + expect(script).toContain('--query=') + expect(testCase.shell === 'posix' ? plan?.launchCommand : script).toContain( + 'ORCA_HERMES_STARTUP_QUERY' + ) + expect(script).toContain("'--yolo' '--tui'") + expect(script).toContain( + testCase.shell === 'posix' + ? '--query=${__orca_hermes_startup_query}' + : 'Remove-Item Env:ORCA_HERMES_STARTUP_QUERY' + ) + if (testCase.shell === 'posix') { + expect(plan?.launchCommand).toContain('unset ORCA_HERMES_STARTUP_QUERY') + } + }) + + it('uses a sh invocation that POSIX-host PowerShell can parse', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toMatch(/^sh -c /) + expect(plan?.launchCommand).toMatch(/\\0[0-7]{3}/) + expect(plan?.launchCommand).not.toContain("'sh' '-c'") + expect(plan?.launchCommand).not.toContain("'\\''") + }) + + it('moves Hermes command override flags after the chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run privately', + cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, + agentArgs: '--yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + }) + + it.each([ + { + shell: 'powershell' as const, + override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', + expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" + }, + { + shell: 'cmd' as const, + override: 'C:\\Tools\\hermes.exe --tui', + expected: "& 'C:\\Tools\\hermes.exe' 'chat'" + } + ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: testCase.override }, + platform: 'win32', + shell: testCase.shell + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) + }) + + it('removes a configured duplicate chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, + agentArgs: '--provider copilot chat --yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--provider' 'copilot' '--yolo'") + }) + + it('preserves an option value named chat', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --profile chat --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") + }) + + it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'automation prompt', + cmdOverrides: { hermes: 'hermes --query override --cli' }, + agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/--query=/g)).toHaveLength(1) + expect(script).not.toContain('override') + expect(script).not.toContain("'--cli'") + expect(script.match(/'--tui'/g)).toHaveLength(1) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') + }) + + it('preserves wrapper tokens before the Hermes executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") + }) + + it('selects the final Hermes executable token in a wrapper', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'sudo' '-u' 'hermes' 'hermes' 'chat'" + ) + }) + + it('selects the wrapped executable when the wrapper and command both name Hermes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") + expect(script.match(/'chat'/g)).toHaveLength(1) + }) + + it('does not mistake a Hermes option value for a wrapped executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--resume' 'hermes'") + }) + + it('preserves POSIX environment-assignment command prefixes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" + ) + }) + + it('rejects a Hermes command override with no identifiable executable', () => { + expect( + buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'custom-agent --tui' }, + platform: 'linux' + }) + ).toBeNull() + }) + + it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'x'.repeat(24_000), + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toBeNull() + }) + + it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( + 'keeps a complex cmd Hermes query out of command text: %s', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + } + ) + + it('uses the Windows remote default shell for SSH Hermes queries', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run remotely', + cmdOverrides: {}, + platform: 'win32', + isRemote: true + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( + "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" + ) + }) + + it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '界'.repeat(50_000), + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toBeNull() + }) + + it('keeps empty Hermes launches on the interactive TUI command', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('hermes --tui') + expect(plan?.followupPrompt).toBeNull() + }) + it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 924108f7a19..6e3a334dbc1 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -15,10 +15,11 @@ import { } from './tui-agent-startup-shell' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' +import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' +import { planHermesStartupQuery } from './hermes-startup-query' +import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './types' -const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 - export type AgentStartupPlan = { agent: TuiAgent launchCommand: string @@ -54,20 +55,6 @@ function resolveBaseCommand(args: { return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } } -function buildSleepingAgentLaunchConfig(args: { - agentCommand?: string | null - agentArgs?: string | null - agentEnv?: Record | null -}): SleepingAgentLaunchConfig { - return { - ...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}), - agentArgs: args.agentArgs ?? '', - // Why: startupPlan.env may include prompt transport or pane identity env; the - // durable resume snapshot is limited to Orca-managed agent env inputs. - agentEnv: args.agentEnv ? { ...args.agentEnv } : {} - } -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -85,12 +72,13 @@ export function buildAgentStartupPlan(args: { const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] + const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) const baseCommand = resolveBaseCommand({ agent, cmdOverrides, platform, shell, - agentArgs: args.agentArgs, + agentArgs: usesQuery ? null : args.agentArgs, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -141,6 +129,31 @@ export function buildAgentStartupPlan(args: { } } + if (config.promptInjectionMode === 'hermes-query') { + const queryPlan = planHermesStartupQuery({ + baseCommand: baseCommand.command, + agentArgs: args.agentArgs, + prompt: trimmedPrompt, + agentEnv: args.agentEnv, + platform, + shell, + isRemote: args.isRemote + }) + if (!queryPlan) { + return null + } + return { + agent, + // Why: Hermes owns readiness and submission for `chat --query`; Orca + // only bounds and quotes the native invocation before starting the TUI. + launchCommand: queryPlan.command, + expectedProcess: config.expectedProcess, + followupPrompt: null, + launchConfig, + ...(queryPlan.env ? { env: queryPlan.env } : {}) + } + } + if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, @@ -233,22 +246,6 @@ export type AgentDraftLaunchPlan = { startupCommandDelivery?: StartupCommandDelivery } -function inlineDraftPlanFitsPlatform( - plan: AgentDraftLaunchPlan, - platform: NodeJS.Platform -): boolean { - if (platform !== 'win32') { - return true - } - const envChars = Object.entries(plan.env ?? {}).reduce( - (total, [key, value]) => total + key.length + value.length, - 0 - ) - // Why: Windows CreateProcess/env blocks have tight length ceilings. Large - // generated drafts should use the existing post-ready paste fallback. - return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS -} - export function buildAgentDraftLaunchPlan(args: { agent: TuiAgent draft: string @@ -304,7 +301,10 @@ export function buildAgentDraftLaunchPlan(args: { env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } - if (!plan || !inlineDraftPlanFitsPlatform(plan, platform)) { + if ( + !plan || + !inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform }) + ) { return null } return plan