diff --git a/src/cli/handlers/orchestration-run-cli.test.ts b/src/cli/handlers/orchestration-run-cli.test.ts index 4ed57bcb169..7159e802490 100644 --- a/src/cli/handlers/orchestration-run-cli.test.ts +++ b/src/cli/handlers/orchestration-run-cli.test.ts @@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => { }) }) + it('passes Run pagination flags to the runtime', async () => { + callMock.mockResolvedValue({ + result: { runs: [], nextCursor: null } + }) + + await ORCHESTRATION_HANDLERS['orchestration run-list']({ + flags: new Map([ + ['limit', '25'], + ['cursor', 'next-page'] + ]), + client: { call: callMock }, + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.runList', { + limit: 25, + cursor: 'next-page' + }) + }) + + it('opts into bounded Run pagination by default', async () => { + callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } }) + + await ORCHESTRATION_HANDLERS['orchestration run-list']({ + flags: new Map(), + client: { call: callMock }, + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.runList', { + limit: 100, + cursor: undefined + }) + }) + it('passes explicit legacy takeover only when requested', async () => { callMock.mockResolvedValue({ result: { run: { id: 'run_adopted', objective: 'Recovered work' } } diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 0e87bc54d46..92a149cbcde 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -22,6 +22,7 @@ import type { import type { NativeChatMessage } from '../../shared/native-chat-types' import type { RuntimeTerminalRead } from '../../shared/runtime-types' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { formatMessageReadOnlyTag, formatOrchestrationCheckText, @@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record = { ) }, - 'orchestration run-list': async ({ client, json }) => { + 'orchestration run-list': async ({ flags, client, json }) => { const result = await client.call<{ runs: { id: string; objective: string; legacy: number }[] - }>('orchestration.runList', {}) - printResult(result, json, (r) => - r.runs.length === 0 - ? 'No Runs found.' - : r.runs - .map( - (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` - ) - .join('\n') - ) + nextCursor: string | null + }>('orchestration.runList', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, + cursor: getOptionalStringFlag(flags, 'cursor') + }) + printResult(result, json, (r) => { + const rows = + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows + }) }, 'orchestration run-show': async ({ flags, client, json }) => { diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index d2bf2d4e2ed..f90f6f99872 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ { path: ['orchestration', 'run-list'], summary: 'List lightweight orchestration Runs', - usage: 'orca orchestration run-list [--json]', - allowedFlags: [...GLOBAL_FLAGS] + usage: 'orca orchestration run-list [--limit ] [--cursor ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor'] }, { path: ['orchestration', 'run-show'], diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts new file mode 100644 index 00000000000..e4855640ada --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -0,0 +1,73 @@ +import type { PtyBackgroundStreamEvent } from '../providers/types' +import { combineUnsubscribes } from './combine-unsubscribes' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' + +export class DaemonPtyAdapterSubscriptionFanout { + private unsubscribers: (() => void)[] = [] + private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = [] + private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = [] + + constructor( + private readonly adapters: readonly DaemonPtyAdapter[], + onAdapterExit: (id: string) => void + ) { + for (const adapter of adapters) { + this.unsubscribers.push( + adapter.onData((payload) => { + for (const listener of this.dataListeners) { + listener(payload) + } + }), + adapter.onExit((payload) => { + onAdapterExit(payload.id) + for (const listener of this.exitListeners) { + listener(payload) + } + }) + ) + } + } + + onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void { + this.dataListeners.push(callback) + return () => { + const index = this.dataListeners.indexOf(callback) + if (index !== -1) { + this.dataListeners.splice(index, 1) + } + } + } + + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + return combineUnsubscribes( + this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback)) + ) + } + + // Why: main subscribes on the routed provider, so without this the dead-endpoint + // fan-out never reaches the renderer and only the written pane recovers (STA-2373). + onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { + return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback))) + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void { + this.exitListeners.push(callback) + return () => { + const index = this.exitListeners.indexOf(callback) + if (index !== -1) { + this.exitListeners.splice(index, 1) + } + } + } + + dispose(): void { + for (const unsubscribe of this.unsubscribers.splice(0)) { + unsubscribe() + } + } +} diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts new file mode 100644 index 00000000000..41c1d560286 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -0,0 +1,15 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' + +export type DaemonPtyRouterDataEvent = { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +} + +export type DaemonPtyRouterExitEvent = { + id: string + code: number + incarnationId?: PtyIncarnationId +} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 315c1d0bb31..0f07da4dc18 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,5 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { combineUnsubscribes } from './combine-unsubscribes' +import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { IPtyProvider, PtyBackgroundStreamEvent, @@ -8,48 +8,23 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { probePtyOwners } from './daemon-pty-liveness-probe' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' +import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() - private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - }) => void)[] = [] + private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current this.legacy = opts.legacy - - for (const adapter of this.allAdapters()) { - this.unsubscribers.push( - adapter.onData((payload) => { - for (const listener of this.dataListeners) { - listener(payload) - } - }), - adapter.onExit((payload) => { - this.sessionAdapters.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) - } - }) - ) - } + this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => { + this.sessionAdapters.delete(id) + }) } async discoverLegacySessions(): Promise { @@ -231,52 +206,24 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.getProfiles() } - onData( - callback: (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void - ): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } + onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void { + return this.subscriptions.onData(callback) } onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - return combineUnsubscribes( - this.allAdapters().map((adapter) => adapter.onBackgroundStreamEvent(callback)) - ) + return this.subscriptions.onBackgroundStreamEvent(callback) } - // Why: main subscribes on the routed provider, so without this the dead-endpoint - // fan-out never reaches the renderer and only the written pane recovers (STA-2373). onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - return combineUnsubscribes( - this.allAdapters().map((adapter) => adapter.onWriteUnavailable(callback)) - ) + return this.subscriptions.onWriteUnavailable(callback) } - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} + onReplay(callback: (payload: { id: string; data: string }) => void): () => void { + return this.subscriptions.onReplay(callback) } - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } + onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void { + return this.subscriptions.onExit(callback) } ackColdRestore(sessionId: string): void { @@ -314,9 +261,7 @@ export class DaemonPtyRouter implements IPtyProvider { } dispose(): void { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() for (const adapter of this.allAdapters()) { adapter.dispose() } @@ -330,15 +275,11 @@ export class DaemonPtyRouter implements IPtyProvider { // Without this, each restart leaked a router instance pinned by the legacy // adapters' listener arrays (one pair per adapter per restart). disposeRouterOnly(): void { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() } async disconnectOnly(): Promise { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly())) } diff --git a/src/main/daemon/json-utf8-byte-length.test.ts b/src/main/daemon/json-utf8-byte-length.test.ts deleted file mode 100644 index fd05398907d..00000000000 --- a/src/main/daemon/json-utf8-byte-length.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { jsonUtf8ByteLength } from './json-utf8-byte-length' - -describe('jsonUtf8ByteLength', () => { - it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => { - const values: unknown[] = [ - '', - '"\\\b\t\n\f\r\u0000\u001f', - 'plain ASCII', - 'é漢😀', - '\ud800 lone high \udc00 lone low', - { - omitted: undefined, - finite: -1.25e100, - nonFinite: Number.POSITIVE_INFINITY, - nested: ['😀', undefined, null, { control: '\u0001' }] - } - ] - - for (const value of values) { - const json = JSON.stringify(value) - expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8')) - } - }) - - it('rejects the same unsupported structural values as JSON.stringify', () => { - const circular: Record = {} - circular.self = circular - - expect(() => jsonUtf8ByteLength(circular)).toThrow('circular') - expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt') - }) -}) diff --git a/src/main/daemon/json-utf8-byte-length.ts b/src/main/daemon/json-utf8-byte-length.ts deleted file mode 100644 index dafc8b6970a..00000000000 --- a/src/main/daemon/json-utf8-byte-length.ts +++ /dev/null @@ -1,94 +0,0 @@ -function jsonStringUtf8Bytes(value: string): number { - let bytes = 2 - for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index) - if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) { - bytes += 2 - } else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) { - bytes += 2 - } else if (codeUnit < 0x20) { - bytes += 6 - } else if (codeUnit < 0x80) { - bytes += 1 - } else if (codeUnit < 0x800) { - bytes += 2 - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4 - index += 1 - } else { - bytes += 6 - } - } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { - bytes += 6 - } else { - bytes += 3 - } - } - return bytes -} - -export function jsonUtf8ByteLength(value: unknown): number { - const activeObjects = new Set() - - const measure = (current: unknown, arrayElement: boolean): number | null => { - if (current === null) { - return 4 - } - switch (typeof current) { - case 'string': - return jsonStringUtf8Bytes(current) - case 'boolean': - return current ? 4 : 5 - case 'number': - return Number.isFinite(current) ? JSON.stringify(current).length : 4 - case 'undefined': - case 'function': - case 'symbol': - return arrayElement ? 4 : null - case 'bigint': - throw new TypeError('Do not know how to serialize a BigInt') - case 'object': - break - } - - const object = current as object - if (activeObjects.has(object)) { - throw new TypeError('Converting circular structure to JSON') - } - activeObjects.add(object) - try { - if (Array.isArray(object)) { - let bytes = 2 - for (let index = 0; index < object.length; index += 1) { - if (index > 0) { - bytes += 1 - } - bytes += measure(object[index], true) ?? 4 - } - return bytes - } - - let bytes = 2 - let entries = 0 - for (const key of Object.keys(object)) { - const propertyBytes = measure((object as Record)[key], false) - if (propertyBytes === null) { - continue - } - bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes - entries += 1 - } - return bytes - } finally { - activeObjects.delete(object) - } - } - - const bytes = measure(value, false) - if (bytes === null) { - throw new TypeError('Value is not JSON serializable') - } - return bytes -} diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts new file mode 100644 index 00000000000..00be8f39eee --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalSnapshot } from './types' +import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer' + +function snapshot(overrides: Partial = {}): TerminalSnapshot { + return { + snapshotAnsi: 'visible', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/workspace', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + cols: 80, + rows: 24, + scrollbackLines: 0, + ...overrides + } +} + +const metadata = { + cwd: '/workspace', + generation: 1, + checkpointedAt: '2026-07-29T00:00:00.000Z' +} + +describe('terminal checkpoint serializer', () => { + it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => { + const input = snapshot({ + snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode( + 0x00, + 0x08, + 0x09, + 0x0a, + 0x0c, + 0x0d, + 0x1f, + 0xd800, + 0xdc00 + )}`, + oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }] + }) + const expected = JSON.stringify({ + snapshotAnsi: input.snapshotAnsi, + scrollbackAnsi: input.scrollbackAnsi, + oscLinks: input.oscLinks, + rehydrateSequences: input.rehydrateSequences, + cwd: metadata.cwd, + cols: input.cols, + rows: input.rows, + modes: input.modes, + scrollbackLines: input.scrollbackLines, + generation: metadata.generation, + checkpointedAt: metadata.checkpointedAt + }) + const exactBytes = Buffer.byteLength(expected, 'utf8') + + await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe( + expected + ) + }) + + it('rejects multibyte input whose code-unit length fits under the byte cap', async () => { + const input = snapshot({ + scrollbackAnsi: 'é\r\n'.repeat(100), + scrollbackLines: 100 + }) + const expected = JSON.stringify({ + snapshotAnsi: input.snapshotAnsi, + scrollbackAnsi: input.scrollbackAnsi, + oscLinks: input.oscLinks, + rehydrateSequences: input.rehydrateSequences, + cwd: metadata.cwd, + cols: input.cols, + rows: input.rows, + modes: input.modes, + scrollbackLines: input.scrollbackLines, + generation: metadata.generation, + checkpointedAt: metadata.checkpointedAt + }) + const maxBytes = expected.length + 1 + + expect(expected.length).toBeLessThan(maxBytes) + expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes) + + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes) + + expect(serialized).not.toBe(expected) + expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes) + }) + + it('does not materialize and rescan a passing candidate', async () => { + let reads = 0 + const input = snapshot() + Object.defineProperty(input, 'snapshotAnsi', { + enumerable: true, + get: () => { + reads += 1 + return 'visible' + } + }) + const stringify = vi.spyOn(JSON, 'stringify') + const byteLength = vi.spyOn(Buffer, 'byteLength') + + try { + await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024) + + expect({ + fullCandidateStringifies: stringify.mock.calls.filter( + ([value]) => typeof value === 'object' && value !== null + ).length, + byteLengthCalls: byteLength.mock.calls.length, + snapshotReads: reads + }).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 }) + } finally { + stringify.mockRestore() + byteLength.mockRestore() + } + }) + + it('rejects an oversized escaped candidate without materializing it', async () => { + const oversized = String.fromCharCode(0).repeat(100_000) + const stringify = vi.spyOn(JSON, 'stringify') + + try { + await serializeTerminalCheckpointWithinLimit( + snapshot({ snapshotAnsi: oversized }), + metadata, + 512 + ) + + const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => { + return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized + }) + expect(materializedOversizedCandidate).toBe(false) + } finally { + stringify.mockRestore() + } + }) +}) diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index 595d42fd6a2..afa852a1bc0 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -1,7 +1,6 @@ import type { TerminalCheckpointFile, TerminalSnapshot } from './types' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { HeadlessEmulator } from './headless-emulator' -import { jsonUtf8ByteLength } from './json-utf8-byte-length' type CheckpointMetadata = { cwd: string | null @@ -32,15 +31,187 @@ function checkpointFile( } } +class BoundedJsonWriter { + private output = '' + private chunk = '' + private bytes = 0 + private exceeded = false + + constructor(private readonly maxBytes: number) {} + + append(value: string, bytes: number): boolean { + if (this.bytes + bytes > this.maxBytes) { + this.exceeded = true + this.output = '' + this.chunk = '' + return false + } + this.bytes += bytes + this.chunk += value + if (this.chunk.length >= 16 * 1024) { + this.output += this.chunk + this.chunk = '' + } + return true + } + + result(): string | null { + return this.exceeded ? null : this.output + this.chunk + } +} + +function escapedCodeUnit(codeUnit: number): string | null { + switch (codeUnit) { + case 0x08: + return '\\b' + case 0x09: + return '\\t' + case 0x0a: + return '\\n' + case 0x0c: + return '\\f' + case 0x0d: + return '\\r' + case 0x22: + return '\\"' + case 0x5c: + return '\\\\' + default: + return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null + } +} + +function appendJsonString(writer: BoundedJsonWriter, value: string): boolean { + if (!writer.append('"', 1)) { + return false + } + let spanStart = 0 + let spanBytes = 0 + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index) + let escaped = escapedCodeUnit(codeUnit) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + spanBytes += 4 + index += 1 + } else { + escaped = `\\u${codeUnit.toString(16)}` + } + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + escaped = `\\u${codeUnit.toString(16)}` + } else if (escaped === null) { + spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3 + } + + if (escaped !== null) { + if ( + (index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) || + !writer.append(escaped, escaped.length) + ) { + return false + } + spanStart = index + 1 + spanBytes = 0 + } else if (index + 1 - spanStart >= 16 * 1024) { + if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) { + return false + } + spanStart = index + 1 + spanBytes = 0 + } + } + if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) { + return false + } + return writer.append('"', 1) +} + +function omittedByJson(value: unknown): boolean { + return value === undefined || typeof value === 'function' || typeof value === 'symbol' +} + +function appendJsonValue( + writer: BoundedJsonWriter, + value: unknown, + activeObjects: Set +): boolean { + if (value === null) { + return writer.append('null', 4) + } + switch (typeof value) { + case 'string': + return appendJsonString(writer, value) + case 'boolean': + return writer.append(value ? 'true' : 'false', value ? 4 : 5) + case 'number': { + const json = Number.isFinite(value) ? JSON.stringify(value) : 'null' + return writer.append(json, json.length) + } + case 'bigint': + throw new TypeError('Do not know how to serialize a BigInt') + case 'undefined': + case 'function': + case 'symbol': + return false + case 'object': + break + } + + if (activeObjects.has(value)) { + throw new TypeError('Converting circular structure to JSON') + } + activeObjects.add(value) + try { + if (Array.isArray(value)) { + if (!writer.append('[', 1)) { + return false + } + for (let index = 0; index < value.length; index += 1) { + if (index > 0 && !writer.append(',', 1)) { + return false + } + const entry = value[index] + if (omittedByJson(entry)) { + if (!writer.append('null', 4)) { + return false + } + } else if (!appendJsonValue(writer, entry, activeObjects)) { + return false + } + } + return writer.append(']', 1) + } + + if (!writer.append('{', 1)) { + return false + } + let entries = 0 + for (const key of Object.keys(value)) { + const entry = (value as Record)[key] + if (omittedByJson(entry)) { + continue + } + if ( + (entries > 0 && !writer.append(',', 1)) || + !appendJsonString(writer, key) || + !writer.append(':', 1) || + !appendJsonValue(writer, entry, activeObjects) + ) { + return false + } + entries += 1 + } + return writer.append('}', 1) + } finally { + activeObjects.delete(value) + } +} + function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null { - if (jsonUtf8ByteLength(checkpoint) > maxBytes) { - return null - } - const json = JSON.stringify(checkpoint) - if (Buffer.byteLength(json, 'utf8') > maxBytes) { - throw new Error('Terminal checkpoint size estimator mismatch') - } - return json + const writer = new BoundedJsonWriter(maxBytes) + appendJsonValue(writer, checkpoint, new Set()) + return writer.result() } async function replaySnapshot(snapshot: TerminalSnapshot): Promise { diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts new file mode 100644 index 00000000000..49fd190e469 --- /dev/null +++ b/src/main/destination-serialized-local-rename.test.ts @@ -0,0 +1,248 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' + +const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({ + copyFileMock: vi.fn(), + handleMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + renameMock: vi.fn() +})) +const handlers = new Map Promise>() + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + copyFile: copyFileMock, + lstat: lstatMock, + realpath: realpathMock, + rename: renameMock + } +}) + +import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename' +import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations' +import { RuntimeFileCommands } from './runtime/orca-runtime-files' + +const REPO_PATH = path.resolve('/workspace/repo') +const store = { + getRepos: () => [ + { id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) +} + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function mockStats(dev: number, ino: number) { + return { dev, ino, isDirectory: () => false } +} + +function createRuntimeCommands(): RuntimeFileCommands { + return new RuntimeFileCommands({ + requireStore: () => store, + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH } + }) + } as never) +} + +function createGate(): { promise: Promise; release: () => void } { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +async function reachNextMacrotask(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('renameLocalPathSerializedByDestination', () => { + beforeEach(() => { + handlers.clear() + copyFileMock.mockReset() + handleMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() + renameMock.mockReset() + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + lstatMock.mockRejectedValue(enoent()) + realpathMock.mockImplementation(async (filePath: string) => filePath) + renameMock.mockResolvedValue(undefined) + registerFilesystemMutationHandlers(store as never) + }) + + it('serializes exact Unicode aliases across IPC and runtime entry points', async () => { + const ipcSource = path.join(REPO_PATH, 'ipc-source.md') + const runtimeSource = path.join(REPO_PATH, 'runtime-source.md') + const sharpSDestination = path.join(REPO_PATH, 'Straße.md') + const expandedDestination = path.join(REPO_PATH, 'STRASSE.md') + const firstRenameGate = createGate() + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if ( + destinationExists && + (filePath === sharpSDestination || filePath === expandedDestination) + ) { + return mockStats(1, 30) + } + if (filePath === ipcSource) { + return mockStats(1, 10) + } + if (filePath === runtimeSource) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + destinationExists = true + }) + + const ipcRename = handlers.get('fs:rename')!(null, { + oldPath: ipcSource, + newPath: sharpSDestination + }) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const runtimeRename = createRuntimeCommands().renameFileExplorerPath( + 'id:wt-1', + 'runtime-source.md', + 'STRASSE.md', + undefined, + undefined, + 'local' + ) + await reachNextMacrotask() + const callsWhileFirstBlocked = renameMock.mock.calls.length + firstRenameGate.release() + const results = await Promise.allSettled([ipcRename, runtimeRename]) + + expect(callsWhileFirstBlocked).toBe(1) + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + + it('releases an alias queue after the leading rename errors', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + throw new Error('EACCES') + } + }) + const destination = path.join(REPO_PATH, 'destination.md') + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + destination + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + destination + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(firstRename).rejects.toThrow('EACCES') + await expect(secondRename).resolves.toBeUndefined() + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('does not serialize destinations in unrelated parent directories', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first', 'source.md'), + path.join(REPO_PATH, 'first', 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second', 'source.md'), + path.join(REPO_PATH, 'second', 'beta.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2)) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + }) + + it('serializes symlink aliases of one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + const firstParent = path.join(REPO_PATH, 'parent-link-a') + const secondParent = path.join(REPO_PATH, 'parent-link-b') + const canonicalParent = path.join(REPO_PATH, 'canonical-parent') + realpathMock.mockImplementation(async (filePath: string) => { + if (filePath === firstParent || filePath === secondParent) { + return canonicalParent + } + return filePath + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(firstParent, 'source.md'), + path.join(firstParent, 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(secondParent, 'source.md'), + path.join(secondParent, 'beta.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('scopes conservative serialization to one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + path.join(REPO_PATH, 'dotless-ı.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + path.join(REPO_PATH, 'dotless-I.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/destination-serialized-local-rename.ts b/src/main/destination-serialized-local-rename.ts new file mode 100644 index 00000000000..74e313c8e98 --- /dev/null +++ b/src/main/destination-serialized-local-rename.ts @@ -0,0 +1,50 @@ +import { realpath, rename } from 'node:fs/promises' +import { dirname, normalize } from 'node:path' +import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' + +// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation. +const pendingRenamesByParent = new Map>() + +function isENOENT(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + +async function destinationParentKey(filePath: string): Promise { + const parentPath = dirname(filePath) + try { + return normalize(await realpath(parentPath)) + } catch (error) { + // A missing parent will fail the rename; retain its path so that failure + // does not prevent unrelated destinations from progressing. + if (isENOENT(error)) { + return normalize(parentPath) + } + throw error + } +} + +export async function renameLocalPathSerializedByDestination( + oldPath: string, + newPath: string +): Promise { + const key = await destinationParentKey(newPath) + const previous = pendingRenamesByParent.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + pendingRenamesByParent.set(key, current) + + await previous + try { + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) + await rename(oldPath, newPath) + } finally { + release() + if (pendingRenamesByParent.get(key) === current) { + pendingRenamesByParent.delete(key) + } + } +} diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index dcaeb74b624..97f62de565c 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + it('serializes concurrent local renames targeting the same destination', async () => { + const firstPath = path.resolve('/workspace/repo/first.ts') + const secondPath = path.resolve('/workspace/repo/second.ts') + const destinationPath = path.resolve('/workspace/repo/destination.ts') + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if (filePath === destinationPath && destinationExists) { + return mockStats(1, 30) + } + if (filePath === firstPath) { + return mockStats(1, 10) + } + if (filePath === secondPath) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + destinationExists = true + }) + + const results = await Promise.allSettled([ + handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }), + handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath }) + ]) + + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + it('rejects rename when destination already exists as a true collision', async () => { const oldPath = path.resolve('/workspace/repo/old.ts') const resolvedNewPath = path.resolve('/workspace/repo/new.ts') @@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => { it('allows case-only rename when destination is the same entry in the same parent', async () => { const oldPath = path.resolve('/workspace/repo/README.md') const newPath = path.resolve('/workspace/repo/readme.md') + const canonicalPath = path.resolve('/workspace/repo/README.md') lstatMock.mockImplementation(async (p: string) => { if (p === oldPath || p === newPath) { return mockStats(2, 20) } throw enoent() }) + mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath }) + + await handlers.get('fs:rename')!(null, { oldPath, newPath }) + + expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) + }) + + it('allows a native Unicode alias rename for the same directory entry', async () => { + const oldPath = path.resolve('/workspace/repo/Straße.md') + const newPath = path.resolve('/workspace/repo/STRASSE.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(2, 21) + } + throw enoent() + }) + mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath }) await handlers.get('fs:rename')!(null, { oldPath, newPath }) @@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('fails closed when same-entry realpath encounters a permission error', async () => { + const oldPath = path.resolve('/workspace/repo/README.md') + const newPath = path.resolve('/workspace/repo/readme.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(3, 30) + } + throw enoent() + }) + realpathMock.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }) + ) + + await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({ + code: 'EACCES' + }) + expect(renameMock).not.toHaveBeenCalled() + }) + it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => { const oldPath = path.resolve('/workspace/repo/src/README.md') const newPath = path.resolve('/workspace/repo/docs/readme.md') diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e89ff761b63..27c29460bd6 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -9,7 +9,6 @@ import { open, readdir, realpath, - rename, rm, unlink, writeFile @@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' /** * Re-throw filesystem errors with user-friendly messages. @@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // accidentally write into a symlinked destination name. const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) } ) diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 7f2f49e84d3..28d303d28a6 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -12,6 +12,7 @@ import { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' +import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' import { callRuntimeEnvironment, @@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ return { environment: redactRuntimeEnvironment(environment) } } ) + ipcMain.handle( + 'runtimeEnvironments:verifyAndAddFromPairingCode', + async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => { + const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) + if (result.ok) { + manuallyDisconnectedEnvironmentIds.delete(result.environment.id) + } + return result + } + ) ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) => redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector)) ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts new file mode 100644 index 00000000000..2c74a79c9f4 --- /dev/null +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -0,0 +1,13 @@ +export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', + 'runtimeEnvironments:connect', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' +] as const diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts new file mode 100644 index 00000000000..e39d727d1b0 --- /dev/null +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -0,0 +1,140 @@ +import { + addEnvironmentFromPairingCode, + RuntimeEnvironmentStoreError +} from '../../shared/runtime-environment-store' +import { parseHostAccessLink } from '../../shared/remote-pairing-address' +import { + verifyRemotePairingRuntimeStatus, + type VerifyAndAddRuntimeEnvironmentResult +} from '../../shared/remote-pairing-verification' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { redactRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' + +type VerifyAndAddRuntimeEnvironmentArgs = { + name: string + pairingCode: string + allowLoopback?: boolean +} + +export async function verifyAndAddRuntimeEnvironmentFromPairingCode( + userDataPath: string, + args: VerifyAndAddRuntimeEnvironmentArgs +): Promise { + const parsed = parseHostAccessLink(args.pairingCode) + if (!parsed.ok) { + return { ok: false, kind: 'access-link-invalid', message: parsed.message } + } + if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: 'This access link points back to this device.' + } + } + + let runtimeStatus: RuntimeStatus + try { + const response = await sendRemoteRuntimeRequest( + parsed.value.pairing, + 'status.get', + undefined, + 15_000 + ) + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + return classifyPairingVerificationError(error, parsed.value.displayEndpoint) + } + + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true + let environment: ReturnType + try { + environment = addEnvironmentFromPairingCode(userDataPath, { + ...args, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }) + } catch (error) { + return { + ok: false, + kind: 'environment-save-failed', + message: + error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument' + ? error.message + : 'Orca verified the host but could not save it. Check local settings storage and try again.' + } + } + return { + ok: true, + environment: redactRuntimeEnvironment(environment), + runtimeStatus + } +} + +function classifyPairingVerificationError( + error: unknown, + endpoint: string +): VerifyAndAddRuntimeEnvironmentResult { + if (error instanceof RemoteRuntimeClientError) { + if (error.code === 'invalid_argument') { + return invalidAccessLinkResult() + } + if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This access link is no longer valid. Generate a new link on the other host.' + } + } + if (error.pairingStage === 'host-identity') { + return { + ok: false, + kind: 'host-identity-mismatch', + message: `Orca reached ${endpoint}, but that host does not match this access link.` + } + } + if (error.pairingStage === 'runtime') { + return { + ok: false, + kind: 'connection-interrupted', + message: `The connection to ${endpoint} was interrupted during verification.` + } + } + if (error.pairingStage === 'connect') { + return unreachableHostResult(endpoint) + } + } + if (error instanceof Error) { + return error.message.startsWith('Invalid public key') + ? invalidAccessLinkResult() + : { ok: false, kind: 'connection-interrupted', message: error.message } + } + return unreachableHostResult(endpoint) +} + +function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This access link contains invalid connection details.' + } +} + +function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult { + return { + ok: false, + kind: 'host-unreachable', + message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.` + } +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 83326570582..d0101ee3968 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -5,8 +5,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer } from '../../shared/pairing' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY +} from '../../shared/protocol-version' import * as environmentStore from '../../shared/runtime-environment-store' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' const { handleMock, @@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { }) } +function runtimeStatus(): Record { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: 999_999 + } +} + function handler( channel: string ): (_event: unknown, args: TArgs) => TResult | Promise { @@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(await list(null, undefined)).toEqual([]) }) + it('blocks loopback before verification unless an SSH tunnel is declared', async () => { + registerRuntimeEnvironmentHandlers(store as never) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; kind?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() }) + ).resolves.toMatchObject({ + ok: false, + kind: 'host-unreachable' + }) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it('verifies identity, access, status, and compatibility before saving', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; environment?: { name: string; connectionDependency?: string } } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode(), + allowLoopback: true + }) + + expect(result).toMatchObject({ + ok: true, + environment: { name: 'desk', connectionDependency: 'ssh-tunnel' } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'status.get', + undefined, + 15_000 + ) + expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1) + }) + + it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; environment?: { connectionDependency?: string } } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768'), + allowLoopback: true + }) + + expect(result).toMatchObject({ ok: true }) + expect(result.environment).not.toHaveProperty('connectionDependency') + }) + + it.each([ + [{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'], + [{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'], + [null, 'connection-interrupted'] + ])('does not save a host with rejected status %o', async (status, expectedKind) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: status, + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + ).resolves.toMatchObject({ ok: false, kind: expectedKind }) + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it.each([ + [ + new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', { + pairingStage: 'host-identity', + closeCode: 4001 + }), + 'host-identity-mismatch' + ], + [ + new RemoteRuntimeClientError('unauthorized', 'rejected', { + pairingStage: 'access-grant' + }), + 'access-link-invalid' + ], + [ + new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', { + pairingStage: 'connect' + }), + 'host-unreachable' + ], + [new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid'] + ] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(error) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string; message?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + + expect(result).toMatchObject({ ok: false, kind: expectedKind }) + expect(result.message).not.toContain('4001') + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it('returns a structured failure when a verified host cannot be persisted', async () => { + registerRuntimeEnvironmentHandlers(store as never) + environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string; message?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'environment-save-failed', + message: 'A server named "desk" already exists.' + }) + expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1) + }) + it('requires an explicit Advanced selection before removing the Active Server', async () => { registerRuntimeEnvironmentHandlers(store as never) const add = handler< diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index e01a5d9ae63..6bf71edd634 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -19,19 +19,7 @@ import { resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ - 'runtimeEnvironments:list', - 'runtimeEnvironments:addFromPairingCode', - 'runtimeEnvironments:resolve', - 'runtimeEnvironments:remove', - 'runtimeEnvironments:disconnect', - 'runtimeEnvironments:connect', - 'runtimeEnvironments:getStatus', - 'runtimeEnvironments:call', - 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe' -] as const +import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { environmentId: string diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index adbae1adf7e..1ea6769ff4c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -80,7 +80,6 @@ import { isWatcherProcessFailure, WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' import { rankRuntimeMobileFilePaths, @@ -89,6 +88,7 @@ import { import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId } from '../../shared/execution-host' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 @@ -1728,8 +1728,7 @@ export class RuntimeFileCommands { const store = this.host.requireStore() const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) return { ok: true } } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index a80ed4ac780..145aa862ee1 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -42,6 +42,7 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. @@ -158,6 +159,28 @@ function exposeRunTimestamps(run: RunRow): RunRow { } } +function encodeRunListCursor(run: RunRow): string { + const cursor: RunListCursor = { createdAt: run.created_at, id: run.id } + return Buffer.from(JSON.stringify(cursor)).toString('base64url') +} + +function decodeRunListCursor(value: string): RunListCursor { + try { + const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as RunListCursor).createdAt !== 'string' || + typeof (parsed as RunListCursor).id !== 'string' + ) { + throw new Error('invalid cursor shape') + } + return parsed as RunListCursor + } catch { + throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.') + } +} + function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow { return { ...delivery, @@ -213,8 +236,21 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance. -const SCHEMA_VERSION = 21 +const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 + +export type RunListPage = { + runs: RunRow[] + nextCursor: string | null +} + +type RunListCursor = { + createdAt: string + id: string +} + +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup. +const SCHEMA_VERSION = 22 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -457,6 +493,7 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status); + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle); CREATE TABLE IF NOT EXISTS decision_gates ( id TEXT PRIMARY KEY, @@ -835,6 +872,12 @@ export class OrchestrationDb { if (current < 21) { this.migrateLegacySchedulerLossProvenance() } + if (current < 22) { + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle + ON dispatch_contexts(assignee_handle); + `) + } this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -1245,6 +1288,44 @@ export class OrchestrationDb { // ── Durable mutation receipts ── + private ensureMutationReceiptCapacity(): void { + this.db + .prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { + count: number + } + const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 + if (completedToRemove > 0) { + this.db + .prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ) + .run(completedToRemove) + } + + const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { + count: number + } + if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } + } + beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1267,6 +1348,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( @@ -2221,10 +2303,38 @@ export class OrchestrationDb { return run ? exposeRunTimestamps(run) : undefined } - listRuns(): RunRow[] { - return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map( - exposeRunTimestamps + listRuns(params: { limit?: number; cursor?: string } = {}): RunListPage { + if (params.limit === undefined && params.cursor === undefined) { + const rows = this.db + .prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC') + .all() as RunRow[] + return { runs: rows.map(exposeRunTimestamps), nextCursor: null } + } + const limit = Math.min( + Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT), + ORCHESTRATION_RUN_PAGE_LIMIT ) + const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined + const rows = ( + cursor + ? this.db + .prepare( + `SELECT * FROM runs + WHERE created_at < ? OR (created_at = ? AND id < ?) + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) + : this.db + .prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?') + .all(limit + 1) + ) as RunRow[] + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + return { + runs: pageRows.map(exposeRunTimestamps), + nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null + } } getCurrentRunForPane(paneKey: string): RunRow | undefined { @@ -3748,6 +3858,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4335,6 +4446,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts new file mode 100644 index 00000000000..914321e0646 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -0,0 +1,260 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +const MUTATION_RECEIPT_MAX_ROWS = 10_000 + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertMutationReceipts( + db: OrchestrationDb, + count: number, + state: 'pending' | 'completed' +): void { + sqliteFor(db) + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +describe('OrchestrationDb bounded mutation receipts', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('prunes expired completed receipts but preserves unresolved receipts', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, updated_at + ) VALUES + ('caller', 'expired', 'orchestration.send', 'hash_expired', 'completed', '2000-01-01 00:00:00'), + ('caller', 'unresolved', 'orchestration.send', 'hash_unresolved', 'pending', '2000-01-01 00:00:00'); + `) + + db.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'new', + method: 'orchestration.send', + payloadHash: 'hash_new' + }) + + expect(db.getMutationReceipt('caller', 'expired')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'unresolved')).toMatchObject({ state: 'pending' }) + }) + + it('caps completed receipt count while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + db.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'new', + method: 'orchestration.send', + payloadHash: 'hash_new' + }) + + const count = sqliteFor(db) + .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') + .get() as { count: number } + expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) + }) + + it('fails closed when unresolved receipts alone fill the ledger', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'overflow', + method: 'orchestration.send', + payloadHash: 'hash_overflow' + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' }) + expect(db.getMutationReceipt('caller', 'overflow')).toBeUndefined() + }) + + it('prunes completed receipts before accepting a remote attachment', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + db.createRemoteDispatchAttachment({ + dispatchId: 'ctx_remote_pruned', + taskId: 'task_remote_pruned', + homePeerFingerprint: 'caller', + protocolVersion: 1, + runtimeEpoch: 'worker_epoch', + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'remote_pruned', + method: 'orchestration.federationAttachStart', + payloadHash: 'hash_remote_pruned' + } + }) + + const count = sqliteFor(db) + .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') + .get() as { count: number } + expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) + expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() + }) + + it('rejects a remote attachment when pending receipts fill the ledger', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.createRemoteDispatchAttachment({ + dispatchId: 'ctx_remote_overflow', + taskId: 'task_remote_overflow', + homePeerFingerprint: 'caller', + protocolVersion: 1, + runtimeEpoch: 'worker_epoch', + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'remote_overflow', + method: 'orchestration.federationAttachStart', + payloadHash: 'hash_remote_overflow' + } + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' }) + expect(db.getMutationReceipt('caller', 'remote_overflow')).toBeUndefined() + expect(db.getRemoteDispatchAttachment('ctx_remote_overflow')).toBeUndefined() + }) + + it('guards atomic worker acceptance without changing task state', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'capacity check' }) + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'worker_overflow', + method: 'orchestration.workerStart', + payloadHash: 'hash_worker_overflow' + } + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getTask(task.id)).toMatchObject({ status: 'ready' }) + expect(db.getMutationReceipt('caller', 'worker_overflow')).toBeUndefined() + }) +}) + +describe('OrchestrationDb Run pagination', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('returns stable bounded pages without skipping Runs sharing a timestamp', () => { + db = new OrchestrationDb(':memory:') + const createdIds = Array.from({ length: 5 }, (_, index) => `run_page_${index}`) + const insertRun = sqliteFor(db).prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_pane_key, + consumer_generation, legacy, created_at + ) VALUES (?, ?, ?, ?, 1, 0, '2025-01-01 00:00:00')` + ) + for (const [index, id] of createdIds.entries()) { + insertRun.run( + id, + `Run ${index}`, + `term_coord_${index}`, + `tab_coord_${index}:11111111-1111-4111-8111-111111111111` + ) + } + const seen: string[] = [] + let cursor: string | undefined + + do { + const page = db.listRuns({ limit: 2, cursor }) + expect(page.runs.length).toBeLessThanOrEqual(2) + expect(page.runs.every((run) => !seen.includes(run.id))).toBe(true) + seen.push(...page.runs.map((run) => run.id)) + cursor = page.nextCursor ?? undefined + } while (cursor) + + expect(seen).toEqual(expect.arrayContaining(createdIds)) + expect(new Set(seen).size).toBe(createdIds.length + 1) + }) +}) + +describe('OrchestrationDb dispatch assignee index migration', () => { + let db: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('migrates a populated v21 database idempotently', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + const task = db.createTask({ spec: 'indexed lookup' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle') + oldDb.pragma('user_version = 21') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(22) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_assignee_handle') + ).toBeDefined() + const plan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT * FROM dispatch_contexts + WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .all('term_worker') as { detail: string }[] + expect(plan.map((row) => row.detail).join('\n')).toContain( + 'USING INDEX idx_dispatch_assignee_handle' + ) + + db.close() + db = new OrchestrationDb(dbPath) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22) + expect(db.getDispatchContextById(dispatch.id)).toBeDefined() + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts index 91f8fcacf39..b6889d20c4e 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts @@ -151,7 +151,7 @@ describe('OrchestrationDb legacy contract storage', () => { expect(db.getLegacyAdoption()).toBeUndefined() expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([]) - expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) + expect(db.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) }) it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => { diff --git a/src/main/runtime/orchestration/orchestration-reset-db.test.ts b/src/main/runtime/orchestration/orchestration-reset-db.test.ts index e98ec4230b5..1e0c62e5306 100644 --- a/src/main/runtime/orchestration/orchestration-reset-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-reset-db.test.ts @@ -53,7 +53,7 @@ describe('OrchestrationDb reset scopes', () => { db!.resetAll() - expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) + expect(db!.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) expect(db!.getTask(state.task.id)).toBeUndefined() expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined() expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined() diff --git a/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts b/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts new file mode 100644 index 00000000000..13fd4bd2a3b --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { ORCHESTRATION_RUN_METHODS } from '../rpc/methods/orchestration-runs' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +describe('orchestration Run list compatibility', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('rejects malformed pagination cursors', () => { + const method = ORCHESTRATION_RUN_METHODS.find( + (candidate) => candidate.name === 'orchestration.runList' + )! + + expect(() => method.params!.parse({ cursor: {} })).toThrow() + expect(() => method.params!.parse({ cursor: '' })).toThrow() + }) + + it('preserves the unpaginated RPC result for an old client request', async () => { + db = new OrchestrationDb(':memory:') + const runCount = 101 + sqliteFor(db) + .prepare( + `WITH RECURSIVE run_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM run_numbers WHERE value < ? + ) + INSERT INTO runs (id, objective) + SELECT printf('run_compat_%03d', value), printf('Run %03d', value) + FROM run_numbers` + ) + .run(runCount) + const method = ORCHESTRATION_RUN_METHODS.find( + (candidate) => candidate.name === 'orchestration.runList' + )! + const params = method.params!.parse({}) + const result = (await method.handler(params, { + runtime: { getOrchestrationDb: () => db } + } as never)) as { runs: unknown[] } + + expect(result.runs).toHaveLength(runCount + 1) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 3cf66666ddf..930a056a6ac 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'agent_unconfigured', 'terminal_worktree_mismatch', 'request_mismatch', + 'mutation_ledger_full', 'legacy_read_only', 'orchestration_migration_required', 'operation_unknown', diff --git a/src/main/runtime/rpc/methods/orchestration-runs.ts b/src/main/runtime/rpc/methods/orchestration-runs.ts index 020ec0a4f67..b246cf80e60 100644 --- a/src/main/runtime/rpc/methods/orchestration-runs.ts +++ b/src/main/runtime/rpc/methods/orchestration-runs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean, OptionalString, requiredString } from '../schemas' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination' import type { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -16,7 +17,10 @@ const RunUseParams = z.object({ }) const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) -const RunListParams = z.object({}) +const RunListParams = z.object({ + limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), + cursor: z.string().min(1).optional() +}) const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string { @@ -104,7 +108,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.runList', params: RunListParams, - handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() }) + handler: (params, { runtime }) => runtime.getOrchestrationDb().listRuns(params) }), defineMethod({ name: 'orchestration.runShow', diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index b5a850a7781..a1282932721 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -164,7 +164,7 @@ describe('orchestration RPC methods', () => { await expect( call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' }) ).rejects.toMatchObject({ code: 'stable_pane_required' }) - expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0) + expect(db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(0) }) it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => { diff --git a/src/main/runtime/windows-drive-listing.test.ts b/src/main/runtime/windows-drive-listing.test.ts index 1d7387e7932..4ceef94bb55 100644 --- a/src/main/runtime/windows-drive-listing.test.ts +++ b/src/main/runtime/windows-drive-listing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { Stats } from 'node:fs' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' @@ -20,6 +20,10 @@ describe('isServerDriveListRequest', () => { }) describe('listWindowsDrives', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + const statOnly = (mounted: string[]) => async (p: string) => { if (!mounted.includes(p)) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -51,14 +55,62 @@ describe('listWindowsDrives', () => { expect(result.entries.map((e) => e.name)).toEqual(['C:\\']) }) - it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => { + it('skips an unreadable drive with an unexpected errno and logs it', async () => { + const error = Object.assign(new Error('EIO'), { code: 'EIO' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const statPath = async (p: string): Promise => { if (p === 'D:\\') { - throw Object.assign(new Error(code), { code }) + throw error } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } - await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code }) + const result = await listWindowsDrives(statPath) + + expect(result.entries).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', { + root: 'D:\\', + error + }) + }) + + it('surfaces programming errors from isDirectory', async () => { + const error = new TypeError('programmer bug') + const statPath = async (p: string): Promise => { + if (p === 'D:\\') { + return { + isDirectory: () => { + throw error + } + } as unknown as Stats + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + await expect(listWindowsDrives(statPath)).rejects.toBe(error) + }) + + it('returns healthy drives among tolerated and unexpected stat failures', async () => { + const error = Object.assign(new Error('EIO'), { code: 'EIO' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const statPath = async (p: string): Promise => { + if (p === 'C:\\' || p === 'F:\\') { + return { isDirectory: () => true } as Stats + } + if (p === 'E:\\') { + throw error + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + const result = await listWindowsDrives(statPath) + + expect(result.entries.map((entry) => entry.name)).toEqual(['C:\\', 'F:\\']) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', { + root: 'E:\\', + error + }) }) }) diff --git a/src/main/runtime/windows-drive-listing.ts b/src/main/runtime/windows-drive-listing.ts index 9b2bbcfdbc0..1b9dbac062c 100644 --- a/src/main/runtime/windows-drive-listing.ts +++ b/src/main/runtime/windows-drive-listing.ts @@ -26,15 +26,16 @@ export async function listWindowsDrives( const roots = await Promise.all( [...DRIVE_LETTERS].map(async (letter) => { const root = `${letter}:\\` + let stats: Stats try { - const stats = await statPath(root) - return stats.isDirectory() ? root : null + stats = await statPath(root) } catch (error) { - if (isExpectedUnavailableDriveError(error)) { - return null + if (!isExpectedUnavailableDriveError(error)) { + console.warn('[windows-drive-listing] Failed to stat drive', { root, error }) } - throw error + return null } + return stats.isDirectory() ? root : null }) ) return { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 93e6a015769..446eecf0cee 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -47,6 +47,7 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, @@ -3317,6 +3318,11 @@ export type PreloadApi = { name: string pairingCode: string }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }) => Promise resolve: (args: { selector: string }) => Promise remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> disconnect: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index b36e128c2ef..c0519f97942 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, @@ -4283,6 +4284,12 @@ const api = { pairingCode: string }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index a4cabb0087b..c305964ca0e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -204,7 +204,7 @@ export default function ProjectCombobox({ diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 771dbed72b2..0a6c2606b01 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { captureFileExplorerOperationGuard, getFileExplorerOperationOwner @@ -26,6 +27,15 @@ afterEach(() => { }) describe('file explorer operation generations', () => { + it('routes floating workspace file mutations to the local host', () => { + const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID) + const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner) + + expect(owner).toEqual({ kind: 'local' }) + expect(guard.route.expectedExecutionHostId).toBe('local') + expect(() => guard.assertCurrent()).not.toThrow() + }) + it('invalidates a nested SSH mutation when that target reconnects', () => { useAppStore.setState({ repos: [], 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 index 1d301916c4d..cb94f03c978 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -14,6 +14,7 @@ import { type WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type FileExplorerOperationRoute = { settings: { activeRuntimeEnvironmentId: string | null } @@ -43,6 +44,9 @@ export function getFileExplorerOperationOwnerFromState( state: FileExplorerOwnerState, worktreeId: string | null | undefined ): FileExplorerOperationOwner { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return { kind: 'local' } + } const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && parsedWorkspace?.type !== 'folder') { const route = resolveWorktreeOperationRoute(state, worktreeId) diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx new file mode 100644 index 00000000000..b347dda0892 --- /dev/null +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { CloudVmSetupGuide } from './CloudVmSetupGuide' + +describe('CloudVmSetupGuide', () => { + afterEach(() => { + useAppStore.setState({ settingsNavigationTarget: null }) + document.body.replaceChildren() + }) + + it('explains creation and opens environment recipe setup', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => root.render()) + + expect(container.textContent).toContain('Create a Cloud VM') + expect(container.textContent).toContain('Create a workspace') + + const button = container.querySelector('button') + if (!button) { + throw new Error('Cloud VM setup button was not rendered') + } + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(useAppStore.getState().settingsNavigationTarget).toEqual({ + pane: 'experimental', + repoId: null, + sectionId: 'ephemeral-vms' + }) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx new file mode 100644 index 00000000000..9ed1a794657 --- /dev/null +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx @@ -0,0 +1,54 @@ +import type React from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' + +export function CloudVmSetupGuide(): React.JSX.Element { + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + + return ( +
+
+
+ {translate('auto.components.settings.CloudVmSetupGuide.title', 'Create a Cloud VM')} +
+

+ {translate( + 'auto.components.settings.CloudVmSetupGuide.description', + 'Cloud VMs are created from environment recipes when you create a workspace.' + )} +

+
+
    +
  1. + {translate( + 'auto.components.settings.CloudVmSetupGuide.setupRecipe', + 'Set up an environment recipe for your cloud provider.' + )} +
  2. +
  3. + {translate( + 'auto.components.settings.CloudVmSetupGuide.createWorkspace', + 'Create a workspace and select that recipe under Run on.' + )} +
  4. +
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx index 401a88277f5..4a6221e866a 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx @@ -113,7 +113,7 @@ describe('EphemeralVmRuntimesSection', () => { document.body.replaceChildren() }) - it('renders active temporary VM runtimes and cleans one up', async () => { + it('renders active Cloud VM runtimes and cleans one up', async () => { const container = await renderSection() await vi.waitFor(() => expect(container.textContent).toContain('Fix Login Race')) @@ -139,7 +139,9 @@ describe('EphemeralVmRuntimesSection', () => { const container = await renderSection() await vi.waitFor(() => - expect(container.textContent).toContain('No temporary VM runtimes need cleanup.') + expect(container.textContent).toContain( + 'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.' + ) ) }) diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx index 876730fc5d2..f5fee751dd5 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx @@ -68,8 +68,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { error instanceof Error ? error.message : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.loadFailed', - 'Couldn’t load temporary VM runtimes.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed', + 'Couldn’t load Cloud VM runtimes.' ) ) } @@ -92,8 +92,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { throw new Error( cleaned.cleanupLastError ?? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast', - 'Couldn’t clean up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast', + 'Couldn’t clean up Cloud VM runtime.' ) ) } @@ -101,12 +101,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { toast.success( cleaned.cleanupStatus === 'disabled' ? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.markedCleaned', - 'Marked temporary VM runtime as cleaned.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmMarkedCleaned', + 'Marked Cloud VM runtime as cleaned.' ) : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleaned', - 'Cleaned up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleaned', + 'Cleaned up Cloud VM runtime.' ) ) } @@ -117,8 +117,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { error instanceof Error ? error.message : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast', - 'Couldn’t clean up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast', + 'Couldn’t clean up Cloud VM runtime.' ) ) await refresh() @@ -171,8 +171,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
{translate( - 'auto.components.settings.EphemeralVmRuntimesSection.title', - 'Temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle', + 'Cloud VM runtimes' )}

@@ -187,12 +187,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { variant="outline" size="icon-sm" aria-label={translate( - 'auto.components.settings.EphemeralVmRuntimesSection.refresh', - 'Refresh temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh', + 'Refresh Cloud VM runtimes' )} title={translate( - 'auto.components.settings.EphemeralVmRuntimesSection.refresh', - 'Refresh temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh', + 'Refresh Cloud VM runtimes' )} onClick={() => void refresh()} disabled={isLoading || cleaningId !== null} @@ -206,12 +206,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {

{isLoading ? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.loading', - 'Checking temporary VM runtimes…' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoading', + 'Checking Cloud VM runtimes…' ) : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.empty', - 'No temporary VM runtimes need cleanup.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmEmptyWithSetup', + 'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.' )}
) : ( diff --git a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx index f4cebdd89c6..543744136cc 100644 --- a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx @@ -29,10 +29,7 @@ export function EphemeralVmsExperimentalSetting({

{translate( @@ -44,8 +41,8 @@ export function EphemeralVmsExperimentalSetting({ updateSettings({ experimentalEphemeralVms: !enabled })} /> diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index a72b2f4550b..8a68688f624 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -120,9 +120,7 @@ describe('EphemeralVmsPane', () => { const container = await renderPane() await vi.waitFor(() => expect(container.textContent).toContain('Cloud Sandbox')) - await vi.waitFor(() => - expect(container.textContent).toContain('Per-Workspace Environments skill') - ) + await vi.waitFor(() => expect(container.textContent).toContain('Cloud VM setup skill')) expect(container.textContent).toContain('What the skill does, with you') const useButton = [...container.querySelectorAll('button')].find( (button) => button.textContent === 'Use in workspace' diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index ba441a15d6e..e2fb92278c5 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -144,8 +144,8 @@ export function EphemeralVmsPane(): React.JSX.Element {

void refresh()} disabled={isLoading} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 1663e35948d..852f1d84337 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -15,9 +15,7 @@ vi.mock('../../store', () => ({ })) vi.mock('./EphemeralVmsPane', () => ({ - EphemeralVmsPane: () => ( -
Per-Workspace Environments pane
- ) + EphemeralVmsPane: () =>
Cloud VM pane
})) vi.mock('../ui/select', async () => { @@ -191,23 +189,23 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('renders per-workspace environments as an off-by-default experimental subsection', () => { + it('renders Cloud VM as an off-by-default experimental subsection', () => { const settings = getDefaultSettings('/tmp') const markup = renderToStaticMarkup( ) const entry = getExperimentalPaneSearchEntries().find( - (searchEntry) => searchEntry.title === 'Per-Workspace Environments' + (searchEntry) => searchEntry.title === 'Cloud VM' ) expect(settings.experimentalEphemeralVms).toBe(false) - expect(markup).toContain('Per-Workspace Environments') + expect(markup).toContain('Cloud VM') expect(markup).toContain('aria-checked="false"') - expect(markup).not.toContain('Per-Workspace Environments pane') + expect(markup).not.toContain('Cloud VM pane') expect(entry?.targetSectionId).toBe('ephemeral-vms') }) - it('enables per-workspace environments through the experimental switch', async () => { + it('enables Cloud VM through the experimental switch', async () => { const updateSettings = vi.fn() const { root, container } = await renderExperimentalPane({ updateSettings }) @@ -215,7 +213,7 @@ describe('ExperimentalPane', () => { '#ephemeral-vms button[role="switch"]' ) if (!switchButton) { - throw new Error('Per-workspace environments switch was not rendered') + throw new Error('Cloud VM switch was not rendered') } await act(async () => { @@ -226,7 +224,7 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('shows per-workspace environment setup controls when enabled', () => { + it('shows Cloud VM setup controls when enabled', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Per-Workspace Environments pane') + expect(markup).toContain('Cloud VM pane') expect(markup).toContain('aria-checked="true"') }) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index d22ce1fadfd..19d8621cbc1 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -34,7 +34,6 @@ import { WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { Button } from '../ui/button' -import { Input } from '../ui/input' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' @@ -48,6 +47,7 @@ import { } from '../ui/dialog' import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator' import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection' +import { CloudVmSetupGuide } from './CloudVmSetupGuide' import { getRuntimeEnvironmentsSearchEntry, getWebRuntimeEnvironmentsSearchEntry @@ -61,6 +61,7 @@ import { getRemoteServerManualUpdateHelp, RemoteServerUpdateStatus } from './RemoteServerUpdateStatus' +import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm' const LOCAL_RUNTIME_VALUE = '__local__' const NO_RUNTIME_VALUE = '__none__' @@ -208,6 +209,7 @@ export function isRuntimeEnvironmentRemovalBlocked( } type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' +type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share' export function getRuntimeServerConnectionState( details: RuntimeHostDetails | undefined @@ -276,12 +278,14 @@ export function RuntimeEnvironmentsPane({ const [pendingSwitchValue, setPendingSwitchValue] = useState(null) const [pendingRemove, setPendingRemove] = useState(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) - const [shareServerFormOpen, setShareServerFormOpen] = useState(false) + const [shareServerFormOpen, setShareServerFormOpen] = useState(true) const [advancedOpen, setAdvancedOpen] = useState(false) + const [workflow, setWorkflow] = useState('connect') const [switchError, setSwitchError] = useState(null) const [removeError, setRemoveError] = useState(null) const [name, setName] = useState('') const [pairingCode, setPairingCode] = useState('') + const [addServerFailure, setAddServerFailure] = useState(null) const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates) const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking) const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning) @@ -308,96 +312,115 @@ export function RuntimeEnvironmentsPane({ ? getRuntimeEnvironmentsSearchEntry() : getWebRuntimeEnvironmentsSearchEntry() - const loadEnvironments = useCallback(async (): Promise => { - if (mountedRef.current) { - setIsLoading(true) - } - try { - const nextEnvironments = await window.api.runtimeEnvironments.list() - const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) - // Why: drop store status for servers no longer saved so stale hosts don't - // linger in the sidebar registry. - useAppStore.getState().setRuntimeEnvironments(nextEnvironments) + const loadEnvironments = useCallback( + async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise => { if (mountedRef.current) { - setEnvironments(visibleEnvironments) - setDetailsByEnvironmentId((current) => { - const next: Record = {} - for (const environment of visibleEnvironments) { - next[environment.id] = current[environment.id] ?? { - status: 'loading', - runtimeStatus: null, - compatibility: null, - error: null - } - } - return next - }) + setIsLoading(true) } - await Promise.allSettled( - visibleEnvironments.map(async (environment) => { - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environment.id, - timeoutMs: 10_000 - }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - // Why: feed the live status into the store so sidebar host pickers - // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) - if (!mountedRef.current) { - return + try { + const nextEnvironments = await window.api.runtimeEnvironments.list() + const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) + // Why: drop store status for servers no longer saved so stale hosts don't + // linger in the sidebar registry. + useAppStore.getState().setRuntimeEnvironments(nextEnvironments) + if (verified) { + useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { + status: verified.runtimeStatus, + checkedAt: Date.now() + }) + } + if (mountedRef.current) { + setEnvironments(visibleEnvironments) + setDetailsByEnvironmentId((current) => { + const next: Record = {} + for (const environment of visibleEnvironments) { + next[environment.id] = + verified?.environmentId === environment.id + ? { + status: 'ready', + runtimeStatus: verified.runtimeStatus, + compatibility: evaluateHostDetails(verified.runtimeStatus), + error: null + } + : (current[environment.id] ?? { + status: 'loading', + runtimeStatus: null, + compatibility: null, + error: null + }) } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'ready', - runtimeStatus, - compatibility: evaluateHostDetails(runtimeStatus), - error: null + return next + }) + } + await Promise.allSettled( + visibleEnvironments + .filter((environment) => environment.id !== verified?.environmentId) + .map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult(response) + // Why: feed the live status into the store so sidebar host pickers + // reflect manual refreshes, not just the settings pane. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility: evaluateHostDetails(runtimeStatus), + error: null + } + })) + } catch (error) { + // Why: record the failed probe (null status) so the sidebar can + // distinguish unreachable from never-checked. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) } - })) - } catch (error) { - // Why: record the failed probe (null status) so the sidebar can - // distinguish unreachable from never-checked. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - checkedAt: Date.now() }) - if (!mountedRef.current) { - return - } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'error', - runtimeStatus: null, - compatibility: null, - error: error instanceof Error ? error.message : String(error) - } - })) - } - }) - ) - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', - 'Failed to load runtime environments.' - ) ) + } catch (error) { + if (mountedRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', + 'Failed to load runtime environments.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsLoading(false) + } } - } finally { - if (mountedRef.current) { - setIsLoading(false) - } - } - }, [mountedRef]) + }, + [mountedRef] + ) useEffect(() => { void loadEnvironments() @@ -427,9 +450,10 @@ export function RuntimeEnvironmentsPane({ setAddServerFormOpen(false) setName('') setPairingCode('') + setAddServerFailure(null) } - const addEnvironment = async (): Promise => { + const addEnvironment = async (allowLoopback: boolean): Promise => { const trimmedName = name.trim() const trimmedPairingCode = pairingCode.trim() if (!trimmedName || !trimmedPairingCode) { @@ -454,17 +478,28 @@ export function RuntimeEnvironmentsPane({ ) return } + setAddServerFailure(null) setIsSaving(true) try { - const result = await window.api.runtimeEnvironments.addFromPairingCode({ + const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ name: trimmedName, - pairingCode: trimmedPairingCode + pairingCode: trimmedPairingCode, + allowLoopback }) + if (!result.ok) { + if (mountedRef.current) { + setAddServerFailure({ kind: result.kind, message: result.message }) + } + return + } if (mountedRef.current) { setName('') setPairingCode('') } - await loadEnvironments() + await loadEnvironments({ + environmentId: result.environment.id, + runtimeStatus: result.runtimeStatus + }) if (!allowLocalRuntime) { const connected = await connectEnvironment(result.environment) if (!connected) { @@ -477,7 +512,7 @@ export function RuntimeEnvironmentsPane({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Saved {{value0}}. Use Advanced > Active Server to make it the default.', + 'Connected to {{value0}}. Use Advanced > Active Server to make it the default.', { value0: result.environment.name } ) ) @@ -723,6 +758,7 @@ export function RuntimeEnvironmentsPane({ } return environments.find((environment) => environment.id === value)?.name ?? 'remote server' } + const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow return ( -
+
+ {( + [ + [ + 'connect', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow', + 'Connect to a host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp', + 'This app joins another machine' + ) + ], + [ + 'share', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow', + 'Share this host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp', + 'Other devices join this machine' + ) + ], + [ + 'cloud-vm', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow', + 'Cloud VM' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp', + 'Manage recipe-created cloud machines' + ) + ] + ] as const + ) + .filter(([value]) => value !== 'share' || canGeneratePairingUrl) + .map(([value, label, description]) => ( + + ))} +
+ +
{addServerFormOpen ? ( -
{ - event.preventDefault() - void addEnvironment() + { + setPairingCode(value) + setAddServerFailure(null) }} - > -
-
- - setName(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e038625857', - 'Dev box' - )} - className="h-8 text-xs" - autoFocus - /> -
-
- - setPairingCode(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.c3d772c514', - 'orca://pair?code=...' - )} - className="h-8 min-w-0 font-mono text-xs" - /> -

- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '} - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4', - 'orca serve --pairing-address ' - )} - {' '} - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.55fcc964cd', - 'on the server and paste the printed pairing URL.' - )} -

-
-
-
- - -
- + onCancel={closeAddServerForm} + onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)} + /> ) : null}
@@ -936,12 +979,17 @@ export function RuntimeEnvironmentsPane({ ) : null}

- {isActive + {environment.connectionDependency === 'ssh-tunnel' ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', - 'Active server for server-routed projects, terminals, and provider checks.' + 'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired', + 'SSH tunnel required' ) - : getHostDetailsSummary(details)} + : isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Active server for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)}

{detailsDescription ? (

- +
+ + +
-
+
+ +
+ + ) +} diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx new file mode 100644 index 00000000000..7515b204883 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { + RuntimePairingGeneratorForm, + type RuntimePairingIntent +} from './RuntimePairingGeneratorForm' + +function renderForm( + intent: RuntimePairingIntent, + selectedAddress: string, + generated?: { + address: string + runtimePairingUrl: string + webClientUrl: string + } +): string { + return renderToStaticMarkup( + + + + ) +} + +describe('RuntimePairingGeneratorForm', () => { + it('uses detected interfaces for another-device intent', () => { + const markup = renderForm('another', '100.76.32.125') + expect(markup).toContain('role="combobox"') + expect(markup).not.toContain('id="runtime-pairing-custom-address"') + }) + + it('requires a dedicated value for custom-address intent', () => { + const emptyMarkup = renderForm('custom', '') + expect(emptyMarkup).toContain('id="runtime-pairing-custom-address"') + expect(emptyMarkup).toContain('disabled=""') + + const populatedMarkup = renderForm('custom', 'openclaw.example.ts.net') + expect(populatedMarkup).toContain('value="openclaw.example.ts.net"') + expect(populatedMarkup).not.toContain('disabled=""') + }) + + it('hides generated links after the selected address changes', () => { + const markup = renderForm('another', '100.76.32.125', { + address: '192.168.1.10', + runtimePairingUrl: 'orca://pair?code=stale-secret', + webClientUrl: 'https://example.test/?pair=stale-secret' + }) + + expect(markup).toContain('The connection address changed.') + expect(markup).not.toContain('stale-secret') + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 5557f61765f..1c5176a999c 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -1,13 +1,18 @@ import { Loader2, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' +import { Input } from '../ui/input' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseServerShareAddress } from '../../../../shared/network/server-share-address' import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows' +import type { RuntimePairingIntent } from './runtime-pairing-link-state' import { translate } from '@/i18n/i18n' +export type { RuntimePairingIntent } from './runtime-pairing-link-state' + type RuntimePairingGeneratorFormProps = { + intent: RuntimePairingIntent loopbackAddress: string networkInterfaces: { name: string; address: string }[] selectedAddress: string @@ -16,6 +21,8 @@ type RuntimePairingGeneratorFormProps = { webClientUrl: string | null runtimePairingUrl: string | null copiedTarget: 'web' | 'pairing' | null + generatedAddress: string | null + onIntentChange: (intent: RuntimePairingIntent) => void onSelectedAddressChange: (address: string) => void onRefreshNetworkInterfaces: () => void onGenerate: () => void @@ -23,6 +30,7 @@ type RuntimePairingGeneratorFormProps = { } export function RuntimePairingGeneratorForm({ + intent, loopbackAddress, networkInterfaces, selectedAddress, @@ -31,136 +39,279 @@ export function RuntimePairingGeneratorForm({ webClientUrl, runtimePairingUrl, copiedTarget, + generatedAddress, + onIntentChange, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerate, onCopy }: RuntimePairingGeneratorFormProps): React.JSX.Element { - const options: AddressOption[] = [ - { - value: loopbackAddress, - label: `${translate( - 'auto.components.settings.RuntimePairingUrlGenerator.de6d5cff95', - 'This computer (' - )}${loopbackAddress})` - }, - ...networkInterfaces.map((networkInterface) => ({ - value: networkInterface.address, - label: `${networkInterface.name} (${networkInterface.address})` - })) - ] + const options: AddressOption[] = networkInterfaces.map((networkInterface) => ({ + value: networkInterface.address, + label: `${networkInterface.name} (${networkInterface.address})` + })) + const generatedIsCurrent = generatedAddress === selectedAddress + const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent + const customAddressResult = + intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const } + const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok + const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok) return ( <>
-
-
-

- {translate( - 'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57', - '127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.' - )} -

+ )} + {staleGeneratedLink && selectedAddress !== '' ? ( +
+ {translate( + 'auto.components.settings.RuntimePairingUrlGenerator.staleAddress', + 'The connection address changed. Generate a new link for {{address}}.', + { address: selectedAddress } + )} +
+ ) : null}
- {webClientUrl ? ( + {generatedIsCurrent && webClientUrl ? ( onCopy('web', webClientUrl)} /> - ) : runtimePairingUrl ? ( + ) : generatedIsCurrent && runtimePairingUrl ? ( ) : null} - {runtimePairingUrl ? ( + {generatedIsCurrent && runtimePairingUrl ? ( ({ + listNetworkInterfaces: vi.fn(), + listRuntimeAccessGrants: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) +vi.mock('./RuntimePairingGeneratorForm', () => ({ + RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( +
{props.selectedAddress}
+ ) +})) + +import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator' +import { runtimePairingLinkCache } from './runtime-pairing-link-state' + +describe('RuntimePairingUrlGenerator', () => { + beforeEach(() => { + runtimePairingLinkCache.selectedAddress = '100.76.32.125' + runtimePairingLinkCache.customAddress = '' + runtimePairingLinkCache.intent = 'another' + runtimePairingLinkCache.generatedAddress = null + runtimePairingLinkCache.runtimePairingUrl = null + runtimePairingLinkCache.webClientUrl = null + runtimePairingLinkCache.runtimePairingDeviceId = null + mocks.listNetworkInterfaces.mockReset() + mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + listNetworkInterfaces: mocks.listNetworkInterfaces, + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + } + } + }) + }) + + afterEach(() => { + cleanup() + }) + + it('keeps the cached address while interfaces are loading', async () => { + let resolveInterfaces!: (value: { interfaces: { name: string; address: string }[] }) => void + mocks.listNetworkInterfaces.mockReturnValue( + new Promise((resolve) => { + resolveInterfaces = resolve + }) + ) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') + + resolveInterfaces({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') + ) + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 590081874ca..7ab34224727 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -6,28 +6,15 @@ import { Label } from '../ui/label' import { RuntimeAccessGrantList } from './RuntimeAccessGrantList' import { translate } from '@/i18n/i18n' import { RuntimePairingGeneratorForm } from './RuntimePairingGeneratorForm' - -const LOOPBACK_ADDRESS = '127.0.0.1' - -// Why: runtime pairing tokens stay valid in the main-process registry; keep the -// last displayed URL across settings collapse/navigation without less-protected storage. -const runtimePairingUrlCache: { - selectedAddress: string - runtimePairingUrl: string | null - webClientUrl: string | null - runtimePairingDeviceId: string | null -} = { - selectedAddress: LOOPBACK_ADDRESS, - runtimePairingUrl: null, - webClientUrl: null, - runtimePairingDeviceId: null -} - -type RuntimePairingUrlGeneratorProps = { - framed?: boolean - showHeader?: boolean - showGeneratorForm?: boolean -} +import { + RUNTIME_PAIRING_LOOPBACK_ADDRESS, + cacheGeneratedRuntimePairingLink, + clearGeneratedRuntimePairingLink, + runtimePairingLinkCache, + selectRuntimePairingIntent, + type RuntimePairingIntent, + type RuntimePairingUrlGeneratorProps +} from './runtime-pairing-link-state' export function RuntimePairingUrlGenerator({ framed = true, @@ -37,15 +24,19 @@ export function RuntimePairingUrlGenerator({ const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>( [] ) - const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress) + const [selectedAddress, setSelectedAddress] = useState(runtimePairingLinkCache.selectedAddress) + const [intent, setIntent] = useState(runtimePairingLinkCache.intent) + const [generatedAddress, setGeneratedAddress] = useState( + runtimePairingLinkCache.generatedAddress + ) const [runtimePairingUrl, setRuntimePairingUrl] = useState( - runtimePairingUrlCache.runtimePairingUrl + runtimePairingLinkCache.runtimePairingUrl ) const [webClientUrl, setWebClientUrl] = useState( - runtimePairingUrlCache.webClientUrl + runtimePairingLinkCache.webClientUrl ) const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState( - runtimePairingUrlCache.runtimePairingDeviceId + runtimePairingLinkCache.runtimePairingDeviceId ) const [runtimeAccessGrants, setRuntimeAccessGrants] = useState([]) const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false) @@ -154,6 +145,20 @@ export function RuntimePairingUrlGenerator({ } }, [loadNetworkInterfaces]) + useEffect(() => { + if (intent !== 'another' || networkInterfaces.length === 0) { + return + } + const addressStillAvailable = networkInterfaces.some( + (networkInterface) => networkInterface.address === selectedAddress + ) + if (!addressStillAvailable) { + const nextAddress = networkInterfaces[0]?.address ?? '' + runtimePairingLinkCache.selectedAddress = nextAddress + setSelectedAddress(nextAddress) + } + }, [intent, networkInterfaces, selectedAddress]) + useEffect(() => { void loadRuntimeAccessGrants() return () => { @@ -162,21 +167,26 @@ export function RuntimePairingUrlGenerator({ }, [loadRuntimeAccessGrants]) const clearGeneratedUrls = (): void => { - runtimePairingUrlCache.runtimePairingUrl = null - runtimePairingUrlCache.webClientUrl = null - runtimePairingUrlCache.runtimePairingDeviceId = null + clearGeneratedRuntimePairingLink() if (mountedRef.current) { setRuntimePairingUrl(null) setWebClientUrl(null) setRuntimePairingDeviceId(null) + setGeneratedAddress(null) } } const generateRuntimePairingUrl = async (): Promise => { + const address = selectedAddress.trim() + runtimePairingLinkCache.selectedAddress = address + setSelectedAddress(address) + if (intent === 'custom') { + runtimePairingLinkCache.customAddress = address + } setIsGeneratingPairing(true) try { const result = await window.api.mobile.getRuntimePairingUrl({ - address: selectedAddress, + address, rotate: true }) if (!result.available) { @@ -191,13 +201,17 @@ export function RuntimePairingUrlGenerator({ } return } - runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl - runtimePairingUrlCache.webClientUrl = result.webClientUrl - runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId + cacheGeneratedRuntimePairingLink({ + address, + pairingUrl: result.pairingUrl, + webClientUrl: result.webClientUrl, + deviceId: result.deviceId + }) if (mountedRef.current) { setRuntimePairingUrl(result.pairingUrl) setWebClientUrl(result.webClientUrl) setRuntimePairingDeviceId(result.deviceId) + setGeneratedAddress(address) } await loadRuntimeAccessGrants() if (mountedRef.current) { @@ -325,8 +339,29 @@ export function RuntimePairingUrlGenerator({ const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : '' const updateSelectedAddress = (address: string): void => { - runtimePairingUrlCache.selectedAddress = address + runtimePairingLinkCache.selectedAddress = address setSelectedAddress(address) + if ( + intent === 'another' && + !networkInterfaces.some((networkInterface) => networkInterface.address === address) + ) { + runtimePairingLinkCache.customAddress = address + runtimePairingLinkCache.intent = 'custom' + setIntent('custom') + } else if (intent === 'custom') { + runtimePairingLinkCache.customAddress = address + } + } + + const updateIntent = (nextIntent: RuntimePairingIntent): void => { + setIntent(nextIntent) + setSelectedAddress( + selectRuntimePairingIntent( + nextIntent, + networkInterfaces, + runtimePairingLinkCache.customAddress + ) + ) } return ( @@ -349,7 +384,8 @@ export function RuntimePairingUrlGenerator({ ) : null} {showGeneratorForm ? ( void loadNetworkInterfaces({ showToastOnError: true })} onGenerate={() => void generateRuntimePairingUrl()} diff --git a/src/renderer/src/components/settings/ephemeral-vms-search.ts b/src/renderer/src/components/settings/ephemeral-vms-search.ts index 16e4f2063ee..654c5d52dda 100644 --- a/src/renderer/src/components/settings/ephemeral-vms-search.ts +++ b/src/renderer/src/components/settings/ephemeral-vms-search.ts @@ -5,10 +5,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getEphemeralVmsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ - title: translate( - 'auto.components.settings.ephemeralVms.search.title', - 'Per-Workspace Environments' - ), + title: translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM'), description: translate( 'auto.components.settings.ephemeralVms.search.description', 'Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.' diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index b15536edd2b..e4ffd8f1a28 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -275,7 +275,7 @@ export function getExperimentalSearchEntry() { ) ), ephemeralVms: findEntry( - translate('auto.components.settings.ephemeralVms.search.title', 'Per-Workspace Environments') + translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM') ) } as const } diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts new file mode 100644 index 00000000000..0b3a1230fb9 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -0,0 +1,64 @@ +export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' + +export type RuntimePairingIntent = 'another' | 'local' | 'custom' + +export type RuntimePairingUrlGeneratorProps = { + framed?: boolean + showHeader?: boolean + showGeneratorForm?: boolean +} + +// Why: pairing tokens remain in the main-process registry, so the last link can +// survive settings navigation without writing credential material to storage. +export const runtimePairingLinkCache: { + selectedAddress: string + customAddress: string + intent: RuntimePairingIntent + generatedAddress: string | null + runtimePairingUrl: string | null + webClientUrl: string | null + runtimePairingDeviceId: string | null +} = { + selectedAddress: '', + customAddress: '', + intent: 'another', + generatedAddress: null, + runtimePairingUrl: null, + webClientUrl: null, + runtimePairingDeviceId: null +} + +export function clearGeneratedRuntimePairingLink(): void { + runtimePairingLinkCache.runtimePairingUrl = null + runtimePairingLinkCache.webClientUrl = null + runtimePairingLinkCache.runtimePairingDeviceId = null + runtimePairingLinkCache.generatedAddress = null +} + +export function cacheGeneratedRuntimePairingLink(args: { + address: string + pairingUrl: string + webClientUrl: string | null + deviceId: string +}): void { + runtimePairingLinkCache.runtimePairingUrl = args.pairingUrl + runtimePairingLinkCache.webClientUrl = args.webClientUrl + runtimePairingLinkCache.runtimePairingDeviceId = args.deviceId + runtimePairingLinkCache.generatedAddress = args.address +} + +export function selectRuntimePairingIntent( + intent: RuntimePairingIntent, + networkInterfaces: { address: string }[], + customAddress: string +): string { + runtimePairingLinkCache.intent = intent + const selectedAddress = + intent === 'local' + ? RUNTIME_PAIRING_LOOPBACK_ADDRESS + : intent === 'another' + ? (networkInterfaces[0]?.address ?? '') + : customAddress + runtimePairingLinkCache.selectedAddress = selectedAddress + return selectedAddress +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 1c31dfebe79..5e2527bf0d6 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Dialog, @@ -19,6 +19,11 @@ import { type EditingTarget } from '../settings/ssh-target-draft' import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { + translateHostAccessLinkError, + translateRemotePairingFailureDescription +} from '@/lib/remote-pairing-copy' import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields' export type AddRemoteHostMode = 'ssh' | 'server' @@ -43,12 +48,18 @@ export function AddRemoteHostDialog({ const [sshForm, setSshForm] = useState(EMPTY_FORM) const [serverName, setServerName] = useState('') const [pairingCode, setPairingCode] = useState('') + const [allowLoopback, setAllowLoopback] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isImporting, setIsImporting] = useState(false) + const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode]) + const serverFormCanSubmit = + serverName.trim() !== '' && + parsedServerLink.ok && + (parsedServerLink.value.endpointKind !== 'loopback' || allowLoopback) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) - const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus) + const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const close = () => { @@ -62,6 +73,7 @@ export function AddRemoteHostDialog({ setSshForm(EMPTY_FORM) setServerName('') setPairingCode('') + setAllowLoopback(false) } const refreshSshTargetMetadata = async () => { @@ -195,16 +207,44 @@ export function AddRemoteHostDialog({ ) return } + if (!parsedServerLink.ok) { + toast.error(translateHostAccessLinkError(parsedServerLink.kind)) + return + } + if (parsedServerLink.value.endpointKind === 'loopback' && !allowLoopback) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked', + 'Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.' + ) + ) + return + } setIsSaving(true) try { - const result = await window.api.runtimeEnvironments.addFromPairingCode({ + const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ name: trimmedName, - pairingCode: trimmedPairingCode + pairingCode: trimmedPairingCode, + allowLoopback }) + if (!result.ok) { + toast.error( + result.kind === 'environment-save-failed' + ? result.message + : translateRemotePairingFailureDescription( + result.kind, + parsedServerLink.value.displayEndpoint + ) + ) + return + } const environments = await window.api.runtimeEnvironments.list() setRuntimeEnvironments(environments) - await refreshRuntimeEnvironmentStatus(result.environment.id) + setRuntimeEnvironmentStatus(result.environment.id, { + status: result.runtimeStatus, + checkedAt: Date.now() + }) toast.success( translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.') ) @@ -260,9 +300,15 @@ export function AddRemoteHostDialog({ { + setPairingCode(value) + setAllowLoopback(false) + }} + allowLoopback={allowLoopback} + onAllowLoopbackChange={setAllowLoopback} onSubmit={() => void saveRemoteServer()} /> ) : ( @@ -306,7 +352,9 @@ export function AddRemoteHostDialog({ onClick={ renderMode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost() } - disabled={isSaving || isImporting} + disabled={ + isSaving || isImporting || (renderMode === 'server' && !serverFormCanSubmit) + } > {isSaving ? translate('auto.components.sidebar.AddRemoteHostDialog.saving', 'Saving...') diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx new file mode 100644 index 00000000000..631eb7b3025 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { RemoteServerFields } from './AddRemoteHostFields' + +function loopbackAccessLink(): string { + return encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'runtime' + }) +} + +describe('RemoteServerFields', () => { + it('associates blocked loopback guidance with the access-link input', () => { + const pairingCode = loopbackAccessLink() + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-invalid="true"') + expect(markup).toContain('aria-describedby="add-server-loopback-blocked"') + expect(markup).toContain('id="add-server-loopback-blocked"') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index 370e847ebee..60a940dd0e0 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -1,7 +1,14 @@ import { useState } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' import { translate } from '@/i18n/i18n' +import { + translateHostAccessLinkError, + translateRemotePairingEndpointKind +} from '@/lib/remote-pairing-copy' +import type { ParseHostAccessLinkResult } from '../../../../shared/remote-pairing-address' import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft' import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields' @@ -120,18 +127,32 @@ export function SshHostFields({ export function RemoteServerFields({ name, pairingCode, + parsedLink, disabled, onNameChange, onPairingCodeChange, + allowLoopback, + onAllowLoopbackChange, onSubmit }: { name: string pairingCode: string + parsedLink: ParseHostAccessLinkResult disabled: boolean onNameChange: (value: string) => void onPairingCodeChange: (value: string) => void + allowLoopback: boolean + onAllowLoopbackChange: (value: boolean) => void onSubmit: () => void }) { + const inputError = pairingCode.trim() !== '' && !parsedLink.ok + const loopbackBlocked = + parsedLink.ok && parsedLink.value.endpointKind === 'loopback' && !allowLoopback + const pairingCodeDescriptionId = inputError + ? 'add-server-pairing-code-error' + : loopbackBlocked + ? 'add-server-loopback-blocked' + : 'add-server-pairing-code-help' return (
onPairingCodeChange(event.target.value)} @@ -171,20 +194,63 @@ export function RemoteServerFields({ )} className="font-mono" /> -

- {translate('auto.components.sidebar.AddRemoteHostDialog.pairingHelpPrefix', 'Run')}{' '} - - {translate( - 'auto.components.sidebar.AddRemoteHostDialog.pairingCommand', - 'orca serve --pairing-address ' - )} - {' '} +

{translate( 'auto.components.sidebar.AddRemoteHostDialog.pairingHelpSuffix', - 'on the server and paste the printed pairing URL.' + 'Create this under Settings → Remote Orca Servers → Share this host on the other computer.' )}

+ {inputError ? ( + + ) : null}
+ {parsedLink.ok ? ( +
+
+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.linkDestination', + 'Link destination' + )} + + {translateRemotePairingEndpointKind(parsedLink.value.endpointKind)} + +
+
{parsedLink.value.displayEndpoint}
+ {parsedLink.value.endpointKind === 'loopback' ? ( + + ) : null} +
+ ) : null} + {loopbackBlocked ? ( + + ) : null} ) } diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index fe4dc94da12..0343bb65bc6 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -116,7 +116,7 @@ describe('settings navigation metadata', () => { expect(sections.find((section) => section.id === 'voice')?.badge).toBeUndefined() }) - it('places per-workspace environments under Experimental instead of as a beta sidebar item', () => { + it('places Cloud VM under Experimental instead of as a beta sidebar item', () => { const sections = buildSettingsNavigationMetadata({ isMac: false, isWindows: false, @@ -125,7 +125,7 @@ describe('settings navigation metadata', () => { }) const experimental = sections.find((section) => section.id === 'experimental') const entry = experimental?.searchEntries.find( - (searchEntry) => searchEntry.title === 'Per-Workspace Environments' + (searchEntry) => searchEntry.title === 'Cloud VM' ) expect(sections.map((section) => section.id)).not.toContain('ephemeral-vms') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6f91319871..6f7177a6bae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -172,7 +172,11 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "Agent Session History is not available for this execution host.", - "runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected." + "runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected.", + "loopbackPairingBlocked": "This access link points back to this device.", + "remotePairingUnreachable": "Cannot reach Orca at {{endpoint}}.", + "remotePairingInvalidDetails": "This access link contains invalid connection details.", + "remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again." }, "web": { "preload": { @@ -374,6 +378,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Enter an Orca access link or bare pairing code.", + "mobileOnly": "This link grants mobile-only access. Generate a link for another Orca client.", + "invalidDestination": "This access link contains an invalid destination.", + "unsupportedDestination": "This access link contains an unsupported destination.", + "nonConnectableDestination": "This access link contains a non-connectable destination.", + "loopback": "Loopback", + "tailscale": "Tailscale address", + "lan": "Private LAN address", + "public": "Public address", + "custom": "Custom hostname" + }, "ensure": { "simulator": { "tab": { @@ -5033,7 +5049,11 @@ "sshImportFailed": "Failed to import SSH config.", "importing": "Importing...", "importSshConfig": "Import ~/.ssh/config", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "Link destination", + "sshTunnel": "I am using an SSH tunnel", + "sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.", + "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Reconnection failed", @@ -6740,7 +6760,22 @@ "updatingServers": "Updating servers…", "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone." + "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", + "workflow": "Remote server workflow", + "connectWorkflow": "Connect to a host", + "connectWorkflowHelp": "This app joins another machine", + "shareWorkflow": "Share this host", + "shareWorkflowHelp": "Other devices join this machine", + "troubleshootWorkflow": "Connection troubleshooting", + "sshTunnelRequired": "SSH tunnel required", + "troubleshootTitle": "Create a new link on the other host", + "troubleshootDescription": "A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.", + "troubleshootStepShare": "On the other computer, open Share this host.", + "troubleshootStepAddress": "Choose Another device and select its Tailscale or LAN address.", + "troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.", + "troubleshootTunnel": "Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.", + "cloudVmWorkflow": "Cloud VM", + "cloudVmWorkflowHelp": "Manage recipe-created cloud machines" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copy {{value0}}" @@ -6779,7 +6814,20 @@ "custom-description": "Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.", "custom-hint": "Enter a host, host:port, or a ws(s):// URL.", "custom-cancel": "Cancel", - "custom-use": "Use address" + "custom-use": "Use address", + "intentQuestion": "Where will this link be opened?", + "anotherDevice": "Another device", + "anotherDeviceHelp": "Tailscale, LAN, or another reachable address", + "localOnly": "This computer only", + "localOnlyHelp": "A browser or Orca client on this computer", + "customAddress": "Custom address", + "customAddressHelp": "SSH tunnel, reverse proxy, or custom hostname", + "recommended": "Recommended", + "localLink": "Local-only link", + "localLinkHelp": "This link only works in a browser or Orca client running on this computer.", + "noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.", + "staleAddress": "The connection address changed. Generate a new link for {{address}}.", + "customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL." }, "Settings": { "3bf149e873": "Project Settings > {{value0}}", @@ -9403,18 +9451,25 @@ "empty": "No temporary VM runtimes need cleanup.", "copyCleanup": "Copy command", "retry": "Retry cleanup", - "cleanup": "Cleanup" + "cleanup": "Cleanup", + "cloudVmLoadFailed": "Couldn’t load Cloud VM runtimes.", + "cloudVmCleanupFailedToast": "Couldn’t clean up Cloud VM runtime.", + "cloudVmMarkedCleaned": "Marked Cloud VM runtime as cleaned.", + "cloudVmCleaned": "Cleaned up Cloud VM runtime.", + "cloudVmTitle": "Cloud VM runtimes", + "cloudVmRefresh": "Refresh Cloud VM runtimes", + "cloudVmLoading": "Checking Cloud VM runtimes…", + "cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe." }, "ephemeralVms": { "search": { - "title": "Per-Workspace Environments", - "description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment." + "description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.", + "cloudVmTitle": "Cloud VM" } }, "EphemeralVmsPane": { "loadError": "Could not load recipes.", "copyError": "Could not copy the prompt.", - "skillTitle": "Per-Workspace Environments skill", "skillDescription": "Sets up, builds, authenticates, and validates repo-owned environment recipes.", "whatTitle": "What the skill does, with you", "whatScaffold": "Writes the recipe & scripts for your provider — connected over an Orca server or SSH.", @@ -9425,13 +9480,16 @@ "copied": "Copied", "recipes": "Recipes", "recipesHelp": "Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.", - "refresh": "Refresh ephemeral VM recipes", "checking": "Checking recipes...", - "none": "No recipes found yet." + "none": "No recipes found yet.", + "cloudVmSkillTitle": "Cloud VM setup skill", + "cloudVmRefresh": "Refresh Cloud VM recipes", + "cloudVmTerminalTitle": "Cloud VM setup", + "cloudVmTerminalAriaLabel": "Cloud VM skill install terminal" }, "ephemeralVmsExperimentalSetting": { "description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.", - "toggleLabel": "Toggle per-workspace environments" + "cloudVmToggleLabel": "Toggle Cloud VM" }, "SourceControlActionRepoOverrideNote": { "agent": "Agent", @@ -9877,6 +9935,52 @@ "logs": "plugin logs", "development": "development plugins" } + }, + "RuntimeHostAccessForm": { + "getLink": "Get an access link from the other host", + "stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.", + "stepChooseAddress": "Choose Another device and select a reachable address.", + "stepCopyLink": "Generate the link, then copy the “Pair another Orca client” link.", + "name": "Name in Orca", + "namePlaceholder": "Linux workstation", + "nameHelp": "This only changes how the computer appears in Orca.", + "accessLink": "Access link", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca shows the destination before connecting. Credentials stay hidden.", + "destination": "Link destination", + "loopbackTitle": "This link points back to this device", + "loopbackDescription": "It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.", + "loopbackRecovery": "On the other computer, create a new link using Another device and choose its Tailscale or LAN address.", + "identityMismatch": "The reached Orca host does not match this access link", + "identityMismatchHelp": "Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.", + "invalidLink": "This access link is no longer valid", + "invalidLinkHelp": "Generate a new access link on the other host and try again.", + "incompatible": "Orca versions are not compatible", + "incompatibleHelp": "Update Orca on this device and the other host, then try again.", + "interrupted": "Connection interrupted", + "interruptedHelp": "The connection stopped during verification. Check the network or SSH tunnel and try again.", + "unavailable": "Host unavailable", + "unavailableHelp": "Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.", + "advanced": "Advanced", + "sshTunnel": "I am using an SSH tunnel to this local address", + "sshTunnelHelp": "Keep the tunnel active while using this connection.", + "headlessHelp": "Using headless orca serve? Run orca serve --pairing-address on the other computer.", + "cancel": "Cancel", + "addWithTunnel": "Add host using tunnel", + "addHost": "Add host", + "connectionDetails": "Connection details", + "endpointKind": "Endpoint kind", + "networkConnection": "Network connection", + "notAttempted": "Not attempted", + "saveFailed": "Could not save the host", + "saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again." + }, + "CloudVmSetupGuide": { + "title": "Create a Cloud VM", + "description": "Cloud VMs are created from environment recipes when you create a workspace.", + "setupRecipe": "Set up an environment recipe for your cloud provider.", + "createWorkspace": "Create a workspace and select that recipe under Run on.", + "openSetup": "Set up environment recipes" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b925b8c47c3..b03e8fa77fc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.", - "runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente." + "runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente.", + "loopbackPairingBlocked": "Este enlace de acceso apunta de nuevo a este dispositivo.", + "remotePairingUnreachable": "No se puede acceder a Orca en {{endpoint}}.", + "remotePairingInvalidDetails": "Este enlace de acceso contiene datos de conexión no válidos." }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Introduce un enlace de acceso de Orca o un código de emparejamiento.", + "mobileOnly": "Este enlace concede acceso solo para dispositivos móviles. Genera un enlace para otro cliente de Orca.", + "invalidDestination": "Este enlace de acceso contiene un destino no válido.", + "unsupportedDestination": "Este enlace de acceso contiene un destino no compatible.", + "nonConnectableDestination": "Este enlace de acceso contiene un destino al que no se puede conectar.", + "loopback": "Bucle local", + "tailscale": "Dirección de Tailscale", + "lan": "Dirección LAN privada", + "public": "Dirección pública", + "custom": "Nombre de host personalizado" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "Importando...", "importSshConfig": "Importar ~/.ssh/config", "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "Destino del enlace", + "sshTunnel": "Estoy usando un túnel SSH", + "sshTunnelHelp": "De lo contrario, este enlace apunta a este dispositivo y no puede identificar el otro equipo.", + "loopbackBlocked": "Activa la opción de túnel SSH o crea un enlace nuevo con la dirección de Tailscale o LAN del otro host." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Error de reconexión", @@ -6676,7 +6695,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "Flujo de servidor remoto", + "connectWorkflow": "Conectarse a un host", + "connectWorkflowHelp": "Esta aplicación se conecta a otra máquina", + "shareWorkflow": "Compartir este host", + "shareWorkflowHelp": "Otros dispositivos se conectan a esta máquina", + "troubleshootWorkflow": "Solución de problemas de conexión", + "sshTunnelRequired": "Se requiere un túnel SSH", + "troubleshootTitle": "Crear un enlace nuevo en el otro host", + "troubleshootDescription": "Un enlace que usa 127.0.0.1 apunta al dispositivo que lo abre, no al equipo que lo creó.", + "troubleshootStepShare": "En el otro equipo, abre Compartir este host.", + "troubleshootStepAddress": "Elige Otro dispositivo y selecciona su dirección de Tailscale o LAN.", + "troubleshootStepRegenerate": "Genera un enlace de acceso nuevo y usa aquí solo el más reciente.", + "troubleshootTunnel": "¿Usas un reenvío local SSH? Vuelve a Conectarse a un host, pega el enlace de bucle local y activa «Estoy usando un túnel SSH» en Avanzado.", + "cloudVmWorkflow": "VM en la nube", + "cloudVmWorkflowHelp": "Gestionar máquinas en la nube creadas mediante recetas" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copiar {{value0}}" @@ -6715,7 +6749,20 @@ "custom-description": "Anuncia una dirección que otro dispositivo pueda alcanzar: un host de LAN o Tailscale, o una URL ws(s):// completa.", "custom-hint": "Introduce un host, host:puerto o una URL ws(s)://.", "custom-cancel": "Cancelar", - "custom-use": "Usar dirección" + "custom-use": "Usar dirección", + "intentQuestion": "¿Dónde se abrirá este enlace?", + "anotherDevice": "Otro dispositivo", + "anotherDeviceHelp": "Tailscale, LAN u otra dirección accesible", + "localOnly": "Solo este equipo", + "localOnlyHelp": "Un navegador o cliente Orca en este equipo", + "customAddress": "Dirección personalizada", + "customAddressHelp": "Túnel SSH, proxy inverso o nombre de host personalizado", + "recommended": "Recomendado", + "localLink": "Enlace solo local", + "localLinkHelp": "Este enlace solo funciona en un navegador o cliente Orca que se ejecute en este equipo.", + "noExternalAddress": "No se encontró una dirección para otro dispositivo. Conecta este equipo a una LAN o a Tailscale, actualiza o elige Dirección personalizada.", + "staleAddress": "La dirección de conexión cambió. Genera un enlace nuevo para {{address}}.", + "customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos." }, "Settings": { "3bf149e873": "Configuración del proyecto > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "No hay runtimes de VM temporales que limpiar.", "copyCleanup": "Copiar comando", "retry": "Reintentar limpieza", - "cleanup": "Limpiar" + "cleanup": "Limpiar", + "cloudVmLoadFailed": "No se pudieron cargar los runtimes de VM en la nube.", + "cloudVmCleanupFailedToast": "No se pudo limpiar el runtime de VM en la nube.", + "cloudVmMarkedCleaned": "El runtime de VM en la nube se marcó como limpiado.", + "cloudVmCleaned": "Se limpió el runtime de VM en la nube.", + "cloudVmTitle": "Runtimes de VM en la nube", + "cloudVmRefresh": "Actualizar runtimes de VM en la nube", + "cloudVmLoading": "Comprobando runtimes de VM en la nube…", + "cloudVmEmptyWithSetup": "Aún no hay runtimes de VM en la nube. Crea uno desde un espacio de trabajo mediante una receta de entorno." }, "ephemeralVms": { "search": { - "title": "Entornos por espacio de trabajo", - "description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda." + "description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda.", + "cloudVmTitle": "VM en la nube" } }, "EphemeralVmsPane": { "loadError": "No se pudieron cargar las recetas.", "copyError": "No se pudo copiar el prompt.", - "skillTitle": "Skill de entornos por espacio de trabajo", "skillDescription": "Configura, construye, autentica y valida recetas de entornos del repositorio.", "whatTitle": "Lo que la skill hace contigo", "whatScaffold": "Escribe la receta y los scripts para tu proveedor, conectados mediante un servidor Orca o SSH.", @@ -9397,13 +9451,16 @@ "copied": "Copiado", "recipes": "Recetas", "recipesHelp": "Las recetas de orca.yaml y de los plugins activados aparecen aquí, listas para iniciar un espacio de trabajo.", - "refresh": "Actualizar recetas de VMs efímeras", "checking": "Comprobando recetas…", - "none": "Aún no se han encontrado recetas." + "none": "Aún no se han encontrado recetas.", + "cloudVmSkillTitle": "Skill de configuración de VM en la nube", + "cloudVmRefresh": "Actualizar recetas de VM en la nube", + "cloudVmTerminalTitle": "Configuración de VM en la nube", + "cloudVmTerminalAriaLabel": "Terminal de instalación de la skill de VM en la nube" }, "ephemeralVmsExperimentalSetting": { "description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.", - "toggleLabel": "Activar o desactivar entornos por espacio de trabajo" + "cloudVmToggleLabel": "Activar o desactivar VM en la nube" }, "SourceControlActionRepoOverrideNote": { "agent": "Agente", @@ -9849,6 +9906,50 @@ "logs": "registros de plugins", "development": "plugins de desarrollo" } + }, + "RuntimeHostAccessForm": { + "getLink": "Obtener un enlace de acceso del otro host", + "stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.", + "stepChooseAddress": "Elige Otro dispositivo y selecciona una dirección accesible.", + "stepCopyLink": "Genera el enlace y, a continuación, copia el enlace «Emparejar otro cliente de Orca».", + "name": "Nombre en Orca", + "namePlaceholder": "Estación de trabajo Linux", + "nameHelp": "Esto solo cambia cómo aparece el equipo en Orca.", + "accessLink": "Enlace de acceso", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca muestra el destino antes de conectarse. Las credenciales permanecen ocultas.", + "destination": "Destino del enlace", + "loopbackTitle": "Este enlace apunta de nuevo a este dispositivo", + "loopbackDescription": "Usa {{endpoint}}, que apunta al dispositivo que abre el enlace, no al otro equipo que lo creó.", + "loopbackRecovery": "En el otro equipo, crea un enlace nuevo con Otro dispositivo y elige su dirección de Tailscale o LAN.", + "identityMismatch": "El host Orca alcanzado no coincide con este enlace de acceso", + "identityMismatchHelp": "Orca llegó a {{endpoint}}, pero ese host no coincide con este enlace. Genera un enlace nuevo en el otro host.", + "invalidLink": "Este enlace de acceso ya no es válido", + "invalidLinkHelp": "Genera un enlace de acceso nuevo en el otro host e inténtalo de nuevo.", + "incompatible": "Las versiones de Orca no son compatibles", + "incompatibleHelp": "Actualiza Orca en este dispositivo y en el otro host e inténtalo de nuevo.", + "interrupted": "Conexión interrumpida", + "interruptedHelp": "La conexión se detuvo durante la verificación. Comprueba la red o el túnel SSH e inténtalo de nuevo.", + "unavailable": "Host no disponible", + "unavailableHelp": "Asegúrate de que Orca se esté ejecutando en el otro host y de que la red o el túnel SSH puedan acceder a {{endpoint}}.", + "advanced": "Avanzado", + "sshTunnel": "Estoy usando un túnel SSH hacia esta dirección local", + "sshTunnelHelp": "Mantén el túnel activo mientras usas esta conexión.", + "headlessHelp": "¿Usas orca serve sin interfaz? Ejecuta orca serve --pairing-address en el otro equipo.", + "cancel": "Cancelar", + "addWithTunnel": "Añadir host mediante túnel", + "addHost": "Añadir host", + "connectionDetails": "Detalles de conexión", + "endpointKind": "Tipo de destino", + "networkConnection": "Conexión de red", + "notAttempted": "No se intentó" + }, + "CloudVmSetupGuide": { + "title": "Crear una VM en la nube", + "description": "Las VMs en la nube se crean a partir de recetas de entorno al crear un espacio de trabajo.", + "setupRecipe": "Configura una receta de entorno para tu proveedor de nube.", + "createWorkspace": "Crea un espacio de trabajo y selecciona esa receta en Ejecutar en.", + "openSetup": "Configurar recetas de entorno" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e371187419..280af3feaad 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。", - "runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。" + "runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。", + "loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。", + "remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。", + "remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。" }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Orca のアクセスリンクまたはペアリングコードを入力してください。", + "mobileOnly": "このリンクはモバイル専用アクセスを許可します。別の Orca クライアント用のリンクを生成してください。", + "invalidDestination": "このアクセスリンクには無効な接続先が含まれています。", + "unsupportedDestination": "このアクセスリンクには未対応の接続先が含まれています。", + "nonConnectableDestination": "このアクセスリンクには接続できない接続先が含まれています。", + "loopback": "ループバック", + "tailscale": "Tailscale アドレス", + "lan": "プライベート LAN アドレス", + "public": "パブリックアドレス", + "custom": "カスタムホスト名" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "インポート中...", "importSshConfig": "~/.ssh/config をインポートします", "sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。", - "advanced": "Advanced" + "advanced": "詳細", + "linkDestination": "リンク先", + "sshTunnel": "SSH トンネルを使用しています", + "sshTunnelHelp": "それ以外の場合、このリンクはこのデバイス自身を指すため、もう一方のコンピューターを識別できません。", + "loopbackBlocked": "SSH トンネルの上書きを有効にするか、もう一方のホストの Tailscale または LAN アドレスを使って新しいリンクを作成してください。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "再接続に失敗しました", @@ -6698,7 +6717,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "リモートサーバーワークフロー", + "connectWorkflow": "ホストに接続", + "connectWorkflowHelp": "このアプリを別のマシンに接続します", + "shareWorkflow": "このホストを共有", + "shareWorkflowHelp": "他のデバイスをこのマシンに接続します", + "troubleshootWorkflow": "接続のトラブルシューティング", + "sshTunnelRequired": "SSH トンネルが必要です", + "troubleshootTitle": "もう一方のホストで新しいリンクを作成", + "troubleshootDescription": "127.0.0.1 を使うリンクは、作成したコンピューターではなく、リンクを開いたデバイス自身を指します。", + "troubleshootStepShare": "もう一方のコンピューターで「このホストを共有」を開きます。", + "troubleshootStepAddress": "「別のデバイス」を選び、Tailscale または LAN アドレスを選択します。", + "troubleshootStepRegenerate": "新しいアクセスリンクを生成し、ここでは最新のリンクだけを使います。", + "troubleshootTunnel": "SSH ローカルフォワードを使う場合は、「ホストに接続」に戻ってループバックリンクを貼り付け、「詳細」で「SSH トンネルを使用しています」を有効にします。", + "cloudVmWorkflow": "クラウド VM", + "cloudVmWorkflowHelp": "レシピで作成したクラウドマシンを管理" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}}をコピー" @@ -6737,7 +6771,20 @@ "custom-description": "他のデバイスから到達できるアドレスを指定します。LAN や Tailscale のホスト、または完全な ws(s):// URL です。", "custom-hint": "ホスト、host:port、または ws(s):// URL を入力してください。", "custom-cancel": "キャンセル", - "custom-use": "アドレスを使用" + "custom-use": "アドレスを使用", + "intentQuestion": "このリンクはどこで開きますか?", + "anotherDevice": "別のデバイス", + "anotherDeviceHelp": "Tailscale、LAN、または到達可能な別のアドレス", + "localOnly": "このコンピューターのみ", + "localOnlyHelp": "このコンピューター上のブラウザーまたは Orca クライアント", + "customAddress": "カスタムアドレス", + "customAddressHelp": "SSH トンネル、リバースプロキシ、またはカスタムホスト名", + "recommended": "推奨", + "localLink": "ローカル専用リンク", + "localLinkHelp": "このリンクは、このコンピューターで実行中のブラウザーまたは Orca クライアントでのみ使えます。", + "noExternalAddress": "別のデバイス用のアドレスが見つかりません。このコンピューターを LAN または Tailscale に接続して更新するか、カスタムアドレスを選んでください。", + "staleAddress": "接続アドレスが変更されました。{{address}} 用の新しいリンクを生成してください。", + "customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。" }, "Settings": { "3bf149e873": "プロジェクト設定 > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "一時的な VM ランタイムにはクリーンアップが必要ありません。", "copyCleanup": "コピーコマンド", "retry": "クリーンアップを再試行します", - "cleanup": "掃除" + "cleanup": "クリーンアップ", + "cloudVmLoadFailed": "クラウド VM ランタイムを読み込めませんでした。", + "cloudVmCleanupFailedToast": "クラウド VM ランタイムをクリーンアップできませんでした。", + "cloudVmMarkedCleaned": "クラウド VM ランタイムをクリーンアップ済みとして記録しました。", + "cloudVmCleaned": "クラウド VM ランタイムをクリーンアップしました。", + "cloudVmTitle": "クラウド VM ランタイム", + "cloudVmRefresh": "クラウド VM ランタイムを更新", + "cloudVmLoading": "クラウド VM ランタイムを確認中…", + "cloudVmEmptyWithSetup": "クラウド VM ランタイムはまだありません。環境レシピを使用してワークスペースから作成してください。" }, "ephemeralVms": { "search": { - "title": "ワークスペースごとの環境", - "description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。" + "description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。", + "cloudVmTitle": "クラウド VM" } }, "EphemeralVmsPane": { "loadError": "レシピを読み込めませんでした。", "copyError": "プロンプトをコピーできませんでした。", - "skillTitle": "ワークスペースごとの環境スキル", "skillDescription": "repo が所有する環境レシピをセットアップ、構築、認証、および検証します。", "whatTitle": "スキルが何をするのか、あなたと一緒に", "whatScaffold": "Orca サーバーまたは SSH 経由で接続されているプロバイダーのレシピとスクリプトを作成します。", @@ -9397,13 +9451,16 @@ "copied": "コピーされました", "recipes": "レシピ", "recipesHelp": "orca.yaml と有効なプラグインのレシピがここに表示され、ワークスペースを起動できます。", - "refresh": "一時的な VM レシピを更新する", "checking": "レシピを確認中...", - "none": "レシピはまだ見つかりません。" + "none": "レシピはまだ見つかりません。", + "cloudVmSkillTitle": "クラウド VM セットアップスキル", + "cloudVmRefresh": "クラウド VM レシピを更新", + "cloudVmTerminalTitle": "クラウド VM のセットアップ", + "cloudVmTerminalAriaLabel": "クラウド VM スキルインストールターミナル" }, "ephemeralVmsExperimentalSetting": { "description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。", - "toggleLabel": "ワークスペースごとの環境を切り替える" + "cloudVmToggleLabel": "クラウド VM を切り替える" }, "SourceControlActionRepoOverrideNote": { "agent": "エージェント", @@ -9849,6 +9906,50 @@ "logs": "プラグインログ", "development": "開発プラグイン" } + }, + "RuntimeHostAccessForm": { + "getLink": "もう一方のホストからアクセスリンクを取得", + "stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。", + "stepChooseAddress": "「別のデバイス」を選び、到達可能なアドレスを選択します。", + "stepCopyLink": "リンクを生成し、「別の Orca クライアントをペアリング」リンクをコピーします。", + "name": "Orca での名前", + "namePlaceholder": "Linux ワークステーション", + "nameHelp": "これは Orca でのコンピューターの表示名だけを変更します。", + "accessLink": "アクセスリンク", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca は接続前に接続先を表示します。認証情報は非表示のままです。", + "destination": "リンク先", + "loopbackTitle": "このリンクはこのデバイス自身を指しています", + "loopbackDescription": "{{endpoint}} はリンクを開いたデバイス自身を指し、リンクを作成したもう一方のコンピューターには接続しません。", + "loopbackRecovery": "もう一方のコンピューターで「別のデバイス」を使って新しいリンクを作成し、Tailscale または LAN アドレスを選んでください。", + "identityMismatch": "接続した Orca ホストがこのアクセスリンクと一致しません", + "identityMismatchHelp": "{{endpoint}} の Orca に接続しましたが、そのホストはこのリンクと一致しません。もう一方のホストで新しいリンクを生成してください。", + "invalidLink": "このアクセスリンクは無効になっています", + "invalidLinkHelp": "もう一方のホストで新しいアクセスリンクを生成し、もう一度お試しください。", + "incompatible": "Orca のバージョンに互換性がありません", + "incompatibleHelp": "このデバイスともう一方のホストの Orca を更新し、もう一度お試しください。", + "interrupted": "接続が中断されました", + "interruptedHelp": "検証中に接続が停止しました。ネットワークまたは SSH トンネルを確認して、もう一度お試しください。", + "unavailable": "ホストを利用できません", + "unavailableHelp": "もう一方のホストで Orca が実行中であり、ネットワークまたは SSH トンネルから {{endpoint}} に到達できることを確認してください。", + "advanced": "詳細", + "sshTunnel": "このローカルアドレスへの SSH トンネルを使用しています", + "sshTunnelHelp": "この接続を使用している間はトンネルを維持してください。", + "headlessHelp": "ヘッドレスの orca serve を使う場合は、もう一方のコンピューターで orca serve --pairing-address を実行してください。", + "cancel": "キャンセル", + "addWithTunnel": "トンネルを使ってホストを追加", + "addHost": "ホストを追加", + "connectionDetails": "接続の詳細", + "endpointKind": "接続先の種類", + "networkConnection": "ネットワーク接続", + "notAttempted": "未試行" + }, + "CloudVmSetupGuide": { + "title": "クラウド VM を作成", + "description": "クラウド VM は、ワークスペースの作成時に環境レシピから作成されます。", + "setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。", + "createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。", + "openSetup": "環境レシピをセットアップ" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5310100e4be..6b24519b6b6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.", - "runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다." + "runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다.", + "loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.", + "remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.", + "remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다." }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Orca 액세스 링크 또는 페어링 코드를 입력하세요.", + "mobileOnly": "이 링크는 모바일 전용 액세스를 허용합니다. 다른 Orca 클라이언트용 링크를 생성하세요.", + "invalidDestination": "이 액세스 링크에 잘못된 대상이 포함되어 있습니다.", + "unsupportedDestination": "이 액세스 링크에 지원되지 않는 대상이 포함되어 있습니다.", + "nonConnectableDestination": "이 액세스 링크에 연결할 수 없는 대상이 포함되어 있습니다.", + "loopback": "루프백", + "tailscale": "Tailscale 주소", + "lan": "사설 LAN 주소", + "public": "공용 주소", + "custom": "사용자 지정 호스트 이름" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "가져오는 중...", "importSshConfig": "~/.ssh/config 가져오기", "sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.", - "advanced": "Advanced" + "advanced": "고급", + "linkDestination": "링크 대상", + "sshTunnel": "SSH 터널을 사용하고 있습니다", + "sshTunnelHelp": "그렇지 않으면 이 링크는 이 기기 자체를 가리키므로 다른 컴퓨터를 식별할 수 없습니다.", + "loopbackBlocked": "SSH 터널 재정의를 활성화하거나 다른 호스트의 Tailscale 또는 LAN 주소를 사용해 새 링크를 만드세요." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "재연결 실패", @@ -6661,7 +6680,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "원격 서버 워크플로", + "connectWorkflow": "호스트에 연결", + "connectWorkflowHelp": "이 앱을 다른 머신에 연결합니다", + "shareWorkflow": "이 호스트 공유", + "shareWorkflowHelp": "다른 기기를 이 머신에 연결합니다", + "troubleshootWorkflow": "연결 문제 해결", + "sshTunnelRequired": "SSH 터널 필요", + "troubleshootTitle": "다른 호스트에서 새 링크 만들기", + "troubleshootDescription": "127.0.0.1을 사용하는 링크는 링크를 만든 컴퓨터가 아니라 링크를 여는 기기 자체를 가리킵니다.", + "troubleshootStepShare": "다른 컴퓨터에서 ‘이 호스트 공유’를 엽니다.", + "troubleshootStepAddress": "‘다른 기기’를 선택하고 Tailscale 또는 LAN 주소를 선택합니다.", + "troubleshootStepRegenerate": "새 액세스 링크를 생성하고 여기에서는 가장 최신 링크만 사용합니다.", + "troubleshootTunnel": "SSH 로컬 포워딩을 사용하나요? ‘호스트에 연결’로 돌아가 루프백 링크를 붙여 넣은 다음 ‘고급’에서 ‘SSH 터널을 사용하고 있습니다’를 활성화하세요.", + "cloudVmWorkflow": "클라우드 VM", + "cloudVmWorkflowHelp": "레시피로 생성한 클라우드 머신 관리" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}} 복사" @@ -6700,7 +6734,20 @@ "custom-description": "다른 기기가 연결할 수 있는 주소를 알립니다. LAN 또는 Tailscale 호스트, 또는 전체 ws(s):// URL입니다.", "custom-hint": "호스트, host:port 또는 ws(s):// URL을 입력하세요.", "custom-cancel": "취소", - "custom-use": "주소 사용" + "custom-use": "주소 사용", + "intentQuestion": "이 링크를 어디에서 열 예정인가요?", + "anotherDevice": "다른 기기", + "anotherDeviceHelp": "Tailscale, LAN 또는 연결 가능한 다른 주소", + "localOnly": "이 컴퓨터만", + "localOnlyHelp": "이 컴퓨터의 브라우저 또는 Orca 클라이언트", + "customAddress": "사용자 지정 주소", + "customAddressHelp": "SSH 터널, 리버스 프록시 또는 사용자 지정 호스트 이름", + "recommended": "권장", + "localLink": "로컬 전용 링크", + "localLinkHelp": "이 링크는 이 컴퓨터에서 실행 중인 브라우저 또는 Orca 클라이언트에서만 작동합니다.", + "noExternalAddress": "다른 기기용 주소를 찾지 못했습니다. 이 컴퓨터를 LAN 또는 Tailscale에 연결한 후 새로 고치거나 사용자 지정 주소를 선택하세요.", + "staleAddress": "연결 주소가 변경되었습니다. {{address}}용 새 링크를 생성하세요.", + "customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요." }, "Settings": { "3bf149e873": "프로젝트 설정 > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "임시 VM 런타임은 정리가 필요하지 않습니다.", "copyCleanup": "복사 명령", "retry": "정리 다시 시도", - "cleanup": "대청소" + "cleanup": "정리", + "cloudVmLoadFailed": "클라우드 VM 런타임을 불러올 수 없습니다.", + "cloudVmCleanupFailedToast": "클라우드 VM 런타임을 정리할 수 없습니다.", + "cloudVmMarkedCleaned": "클라우드 VM 런타임을 정리됨으로 표시했습니다.", + "cloudVmCleaned": "클라우드 VM 런타임을 정리했습니다.", + "cloudVmTitle": "클라우드 VM 런타임", + "cloudVmRefresh": "클라우드 VM 런타임 새로 고침", + "cloudVmLoading": "클라우드 VM 런타임 확인 중…", + "cloudVmEmptyWithSetup": "아직 클라우드 VM 런타임이 없습니다. 환경 레시피를 사용해 워크스페이스에서 생성하세요." }, "ephemeralVms": { "search": { - "title": "워크스페이스별 환경", - "description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요." + "description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요.", + "cloudVmTitle": "클라우드 VM" } }, "EphemeralVmsPane": { "loadError": "레시피를 로드할 수 없습니다.", "copyError": "프롬프트를 복사할 수 없습니다.", - "skillTitle": "워크스페이스별 환경 기술", "skillDescription": "repo 소유 환경 레시피를 설정, 빌드, 인증 및 검증합니다.", "whatTitle": "당신과 함께 기술이 하는 일", "whatScaffold": "Orca 서버 또는 SSH를 통해 연결된 공급자를 위한 레시피 및 스크립트를 작성합니다.", @@ -9397,13 +9451,16 @@ "copied": "복사됨", "recipes": "조리법", "recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.", - "refresh": "임시 VM 레시피 새로 고침", "checking": "레시피 확인 중...", - "none": "아직 레시피를 찾을 수 없습니다." + "none": "아직 레시피를 찾을 수 없습니다.", + "cloudVmSkillTitle": "클라우드 VM 설정 스킬", + "cloudVmRefresh": "클라우드 VM 레시피 새로 고침", + "cloudVmTerminalTitle": "클라우드 VM 설정", + "cloudVmTerminalAriaLabel": "클라우드 VM 스킬 설치 터미널" }, "ephemeralVmsExperimentalSetting": { "description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.", - "toggleLabel": "워크스페이스별 환경 전환" + "cloudVmToggleLabel": "클라우드 VM 전환" }, "SourceControlActionRepoOverrideNote": { "agent": "에이전트", @@ -9849,6 +9906,50 @@ "logs": "플러그인 로그", "development": "개발 플러그인" } + }, + "RuntimeHostAccessForm": { + "getLink": "다른 호스트에서 액세스 링크 가져오기", + "stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.", + "stepChooseAddress": "‘다른 기기’를 선택하고 연결 가능한 주소를 선택합니다.", + "stepCopyLink": "링크를 생성한 다음 “다른 Orca 클라이언트 페어링” 링크를 복사하세요.", + "name": "Orca에서 사용할 이름", + "namePlaceholder": "Linux 워크스테이션", + "nameHelp": "Orca에서 이 컴퓨터가 표시되는 이름만 변경합니다.", + "accessLink": "액세스 링크", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca는 연결하기 전에 대상을 표시하며 자격 증명은 숨겨 둡니다.", + "destination": "링크 대상", + "loopbackTitle": "이 링크는 이 기기 자체를 가리킵니다", + "loopbackDescription": "{{endpoint}}는 링크를 연 기기 자체를 가리키며, 링크를 만든 다른 컴퓨터에는 연결되지 않습니다.", + "loopbackRecovery": "다른 컴퓨터에서 ‘다른 기기’를 사용해 새 링크를 만들고 Tailscale 또는 LAN 주소를 선택하세요.", + "identityMismatch": "연결된 Orca 호스트가 이 액세스 링크와 일치하지 않습니다", + "identityMismatchHelp": "{{endpoint}}의 Orca에 연결했지만 해당 호스트가 이 링크와 일치하지 않습니다. 다른 호스트에서 새 링크를 생성하세요.", + "invalidLink": "이 액세스 링크는 더 이상 유효하지 않습니다", + "invalidLinkHelp": "다른 호스트에서 새 액세스 링크를 생성한 후 다시 시도하세요.", + "incompatible": "Orca 버전이 호환되지 않습니다", + "incompatibleHelp": "이 기기와 다른 호스트의 Orca를 업데이트한 후 다시 시도하세요.", + "interrupted": "연결이 중단되었습니다", + "interruptedHelp": "확인 중 연결이 중단되었습니다. 네트워크 또는 SSH 터널을 확인한 후 다시 시도하세요.", + "unavailable": "호스트를 사용할 수 없습니다", + "unavailableHelp": "다른 호스트에서 Orca가 실행 중이고 네트워크 또는 SSH 터널을 통해 {{endpoint}}에 연결할 수 있는지 확인하세요.", + "advanced": "고급", + "sshTunnel": "이 로컬 주소에 SSH 터널을 사용하고 있습니다", + "sshTunnelHelp": "이 연결을 사용하는 동안 터널을 유지하세요.", + "headlessHelp": "헤드리스 orca serve를 사용하나요? 다른 컴퓨터에서 orca serve --pairing-address 를 실행하세요.", + "cancel": "취소", + "addWithTunnel": "터널을 사용해 호스트 추가", + "addHost": "호스트 추가", + "connectionDetails": "연결 세부 정보", + "endpointKind": "대상 유형", + "networkConnection": "네트워크 연결", + "notAttempted": "시도하지 않음" + }, + "CloudVmSetupGuide": { + "title": "클라우드 VM 만들기", + "description": "클라우드 VM은 워크스페이스를 만들 때 환경 레시피에서 생성됩니다.", + "setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.", + "createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.", + "openSetup": "환경 레시피 설정" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3a948e98e79..3c0d1084e41 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。", - "runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。" + "runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。", + "loopbackPairingBlocked": "此访问链接会指回此设备。", + "remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。", + "remotePairingInvalidDetails": "此访问链接包含无效的连接信息。" }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "请输入 Orca 访问链接或配对代码。", + "mobileOnly": "此链接仅授予移动端访问权限。请为另一个 Orca 客户端生成链接。", + "invalidDestination": "此访问链接包含无效的目标地址。", + "unsupportedDestination": "此访问链接包含不受支持的目标地址。", + "nonConnectableDestination": "此访问链接包含无法连接的目标地址。", + "loopback": "环回地址", + "tailscale": "Tailscale 地址", + "lan": "专用 LAN 地址", + "public": "公网地址", + "custom": "自定义主机名" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "正在导入...", "importSshConfig": "导入 ~/.ssh/config", "sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "链接目标", + "sshTunnel": "我正在使用 SSH 隧道", + "sshTunnelHelp": "否则,此链接会指回此设备,无法识别另一台计算机。", + "loopbackBlocked": "启用 SSH 隧道选项,或使用另一台主机的 Tailscale 或 LAN 地址创建新链接。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "重新连接失败", @@ -6653,15 +6672,30 @@ "advertiseThisApp": "将此应用作为服务器公布", "advertiseThisAppHelp": "创建访问链接,让浏览器、移动客户端或另一个 Orca 客户端连接回这个正在运行的应用。", "runtimeReachable": "可连接到 {{value0}}。", - "updateAvailableOne": "1 update available", - "updatesAvailable": "{{value0}} updates available", - "versionUnavailable": "Orca version unavailable", - "updateServer": "Update", - "reviewServerUpdates": "Server updates", + "updateAvailableOne": "1 个更新可用", + "updatesAvailable": "{{value0}} 个更新可用", + "versionUnavailable": "Orca 版本不可用", + "updateServer": "更新", + "reviewServerUpdates": "服务器更新", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "removeActiveServerBlocked": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。", + "removeActiveServerDescription": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。现有的主机会话将保留。", + "updatingServers": "正在更新服务器…", + "workflow": "远程服务器工作流程", + "connectWorkflow": "连接到主机", + "connectWorkflowHelp": "此应用加入另一台机器", + "shareWorkflow": "共享此主机", + "shareWorkflowHelp": "其他设备加入此机器", + "troubleshootWorkflow": "连接故障排除", + "sshTunnelRequired": "需要 SSH 隧道", + "troubleshootTitle": "在另一台主机上创建新链接", + "troubleshootDescription": "使用 127.0.0.1 的链接会指向打开它的设备,而不是创建它的计算机。", + "troubleshootStepShare": "在另一台计算机上,打开“共享此主机”。", + "troubleshootStepAddress": "选择“其他设备”,然后选择其 Tailscale 或 LAN 地址。", + "troubleshootStepRegenerate": "生成新的访问链接,并仅在此处使用最新的链接。", + "troubleshootTunnel": "正在使用 SSH 本地转发?返回“连接到主机”,粘贴环回链接,然后在“高级”下启用“我正在使用 SSH 隧道”。", + "cloudVmWorkflow": "云虚拟机", + "cloudVmWorkflowHelp": "管理由配方创建的云端机器" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "复制 {{value0}}" @@ -6700,7 +6734,20 @@ "custom-description": "公布其他设备可以访问的地址:LAN 或 Tailscale 主机,或完整的 ws(s):// URL。", "custom-hint": "请输入主机、host:port 或 ws(s):// URL。", "custom-cancel": "取消", - "custom-use": "使用地址" + "custom-use": "使用地址", + "intentQuestion": "此链接将在哪里打开?", + "anotherDevice": "其他设备", + "anotherDeviceHelp": "Tailscale、LAN 或其他可达地址", + "localOnly": "仅此计算机", + "localOnlyHelp": "此计算机上的浏览器或 Orca 客户端", + "customAddress": "自定义地址", + "customAddressHelp": "SSH 隧道、反向代理或自定义主机名", + "recommended": "推荐", + "localLink": "仅本地链接", + "localLinkHelp": "此链接仅在运行于此计算机上的浏览器或 Orca 客户端中有效。", + "noExternalAddress": "未找到其他设备的地址。将此计算机连接到 LAN 或 Tailscale,刷新,或选择“自定义地址”。", + "staleAddress": "连接地址已更改。为 {{address}} 生成新链接。", + "customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。" }, "Settings": { "3bf149e873": "项目设置 > {{value0}}", @@ -6777,7 +6824,7 @@ "dev": "开发工具", "devDescription": "仅用于调试 UI 状态的开发工具。", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -9375,18 +9422,25 @@ "empty": "没有需要清理的临时 VM 运行时。", "copyCleanup": "复制命令", "retry": "重试清理", - "cleanup": "清理" + "cleanup": "清理", + "cloudVmLoadFailed": "无法加载云虚拟机运行时。", + "cloudVmCleanupFailedToast": "无法清理云虚拟机运行时。", + "cloudVmMarkedCleaned": "已将云虚拟机运行时标记为已清理。", + "cloudVmCleaned": "已清理云虚拟机运行时。", + "cloudVmTitle": "云虚拟机运行时", + "cloudVmRefresh": "刷新云虚拟机运行时", + "cloudVmLoading": "正在检查云虚拟机运行时…", + "cloudVmEmptyWithSetup": "还没有云虚拟机运行时。请在创建工作区时使用环境模板来创建。" }, "ephemeralVms": { "search": { - "title": "工作区专属环境", - "description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。" + "description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。", + "cloudVmTitle": "云虚拟机" } }, "EphemeralVmsPane": { "loadError": "无法加载环境模板。", "copyError": "无法复制提示词。", - "skillTitle": "工作区专属环境技能", "skillDescription": "设置、构建、认证并验证仓库自带的环境模板。", "whatTitle": "此技能会与你一起完成", "whatScaffold": "为你的提供商编写环境模板和脚本,可通过 Orca 服务器或 SSH 连接。", @@ -9397,13 +9451,16 @@ "copied": "已复制", "recipes": "环境模板", "recipesHelp": "orca.yaml 和已启用插件中的配方会显示在这里,可用于启动工作区。", - "refresh": "刷新临时 VM 环境模板", "checking": "正在检查环境模板...", - "none": "还没有找到环境模板。" + "none": "还没有找到环境模板。", + "cloudVmSkillTitle": "云虚拟机设置技能", + "cloudVmRefresh": "刷新云虚拟机环境模板", + "cloudVmTerminalTitle": "云虚拟机设置", + "cloudVmTerminalAriaLabel": "云虚拟机技能安装终端" }, "ephemeralVmsExperimentalSetting": { "description": "显示仓库自带按需环境的设置控件和工作区运行目标。", - "toggleLabel": "切换工作区专属环境" + "cloudVmToggleLabel": "切换云虚拟机" }, "SourceControlActionRepoOverrideNote": { "agent": "智能体", @@ -9558,25 +9615,25 @@ "reviewUpdateOne": "1 update available" }, "RemoteServerUpdateDialog": { - "versionUnavailable": "Version unavailable", - "restartingHelp": "Waiting for the replacement server to reconnect on the new version.", - "retry": "Retry", - "update": "Update", - "title": "Update Remote Orca Servers", - "description": "Review paired servers and update supported installs from this Orca client.", - "restartWarning": "Updating restarts these servers. {{value0}} live tabs and {{value1}} terminal panes may briefly disconnect.", - "checking": "Checking paired servers…", - "empty": "No paired Remote Orca Servers.", - "checkAgain": "Check again", - "updating": "Updating servers…", - "updateAll": "Update {{value0}} servers", + "versionUnavailable": "版本不可用", + "restartingHelp": "正在等待替代服务器在新版本上重新连接。", + "retry": "重试", + "update": "更新", + "title": "更新远程 Orca 服务器", + "description": "查看已配对的服务器,并从此 Orca 客户端更新受支持的安装。", + "restartWarning": "更新将重启这些服务器。{{value0}} 个活动标签页和 {{value1}} 个终端窗格可能会短暂断开连接。", + "checking": "正在检查已配对的服务器…", + "empty": "没有已配对的远程 Orca 服务器。", + "checkAgain": "再次检查", + "updating": "正在更新服务器…", + "updateAll": "更新 {{value0}} 台服务器", "noUpdates": "没有可用更新", - "updateOne": "Update server", - "downloadProgress": "{{value0}} download progress", - "liveTabOne": "1 live tab", - "liveTabs": "{{value0}} live tabs", - "livePaneOne": "1 live pane", - "livePanes": "{{value0}} live panes" + "updateOne": "更新服务器", + "downloadProgress": "{{value0}} 下载进度", + "liveTabOne": "1 个活动标签页", + "liveTabs": "{{value0}} 个活动标签页", + "livePaneOne": "1 个活动窗格", + "livePanes": "{{value0}} 个活动窗格" }, "RemoteServerUpdateStatus": { "checking": "检查中…", @@ -9586,13 +9643,13 @@ "offline": "离线", "queued": "已排队", "checkingUpdate": "正在检查更新…", - "downloading": "Downloading…", - "restarting": "Restarting…", - "updated": "Updated", - "failed": "Update failed", - "serviceManagerHelp": "Update Orca through the service manager that starts this server.", - "unpackedHelp": "Development builds must be updated from their source checkout.", - "legacyHelp": "Update this server manually once to enable remote updates." + "downloading": "正在下载…", + "restarting": "正在重启…", + "updated": "已更新", + "failed": "更新失败", + "serviceManagerHelp": "通过启动此服务器的服务管理器更新 Orca。", + "unpackedHelp": "开发版本必须从其源代码检出进行更新。", + "legacyHelp": "手动更新此服务器一次以启用远程更新。" }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", @@ -9849,6 +9906,50 @@ "logs": "插件日志", "development": "开发插件" } + }, + "RuntimeHostAccessForm": { + "getLink": "从另一台主机获取访问链接", + "stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。", + "stepChooseAddress": "选择“其他设备”,然后选择一个可达地址。", + "stepCopyLink": "生成链接,然后复制“配对另一个 Orca 客户端”链接。", + "name": "在 Orca 中的名称", + "namePlaceholder": "Linux 工作站", + "nameHelp": "这仅更改计算机在 Orca 中的显示方式。", + "accessLink": "访问链接", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca 会在连接前显示目标。凭据保持隐藏。", + "destination": "链接目标", + "loopbackTitle": "此链接指向此设备", + "loopbackDescription": "{{endpoint}} 会指向打开链接的设备,而不是创建链接的另一台计算机。", + "loopbackRecovery": "在另一台计算机上,使用“其他设备”创建新链接,并选择其 Tailscale 或 LAN 地址。", + "identityMismatch": "到达的 Orca 主机与此访问链接不匹配", + "identityMismatchHelp": "已连接到 {{endpoint}} 上的 Orca,但该主机与此链接不匹配。请在另一台主机上生成新链接。", + "invalidLink": "此访问链接不再有效", + "invalidLinkHelp": "请在另一台主机上生成新的访问链接,然后重试。", + "incompatible": "Orca 版本不兼容", + "incompatibleHelp": "请更新此设备和另一台主机上的 Orca,然后重试。", + "interrupted": "连接中断", + "interruptedHelp": "验证期间连接已中断。请检查网络或 SSH 隧道,然后重试。", + "unavailable": "主机不可用", + "unavailableHelp": "请确保 Orca 正在另一台主机上运行,并且网络或 SSH 隧道可以连接到 {{endpoint}}。", + "advanced": "高级", + "sshTunnel": "我正在使用 SSH 隧道连接到此本地地址", + "sshTunnelHelp": "使用此连接时保持隧道活跃。", + "headlessHelp": "正在使用无头 orca serve?在另一台计算机上运行 orca serve --pairing-address <可达主机>。", + "cancel": "取消", + "addWithTunnel": "使用隧道添加主机", + "addHost": "添加主机", + "connectionDetails": "连接详情", + "endpointKind": "端点类型", + "networkConnection": "网络连接", + "notAttempted": "未尝试" + }, + "CloudVmSetupGuide": { + "title": "创建云虚拟机", + "description": "云虚拟机会在创建工作区时通过环境模板创建。", + "setupRecipe": "为你的云提供商设置环境模板。", + "createWorkspace": "创建工作区,并在“运行于”中选择该模板。", + "openSetup": "设置环境模板" } }, "right": { diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts index f2dde24571e..1e2438dbce5 100644 --- a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts +++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts @@ -58,11 +58,6 @@ const CASES: GuardCase[] = [ afterFallback: 'such as', label: 'such as main' }, - { - file: 'components/settings/RuntimeEnvironmentsPane.tsx', - afterFallback: 'Run', - label: 'Run orca serve' - }, { file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx', afterFallback: 'Use', diff --git a/src/renderer/src/lib/remote-pairing-copy.ts b/src/renderer/src/lib/remote-pairing-copy.ts new file mode 100644 index 00000000000..72d74ad494b --- /dev/null +++ b/src/renderer/src/lib/remote-pairing-copy.ts @@ -0,0 +1,91 @@ +import type { + HostAccessLinkErrorKind, + RemotePairingEndpointKind +} from '../../../shared/remote-pairing-address' +import type { RemotePairingFailureKind } from '../../../shared/remote-pairing-verification' +import { translate } from '@/i18n/i18n' + +export function translateHostAccessLinkError(kind: HostAccessLinkErrorKind): string { + switch (kind) { + case 'invalid-input': + return translate( + 'auto.lib.remotePairingCopy.invalidInput', + 'Enter an Orca access link or bare pairing code.' + ) + case 'mobile-only': + return translate( + 'auto.lib.remotePairingCopy.mobileOnly', + 'This link grants mobile-only access. Generate a link for another Orca client.' + ) + case 'invalid-destination': + return translate( + 'auto.lib.remotePairingCopy.invalidDestination', + 'This access link contains an invalid destination.' + ) + case 'unsupported-destination': + return translate( + 'auto.lib.remotePairingCopy.unsupportedDestination', + 'This access link contains an unsupported destination.' + ) + case 'non-connectable-destination': + return translate( + 'auto.lib.remotePairingCopy.nonConnectableDestination', + 'This access link contains a non-connectable destination.' + ) + } +} + +export function translateRemotePairingEndpointKind(kind: RemotePairingEndpointKind): string { + switch (kind) { + case 'loopback': + return translate('auto.lib.remotePairingCopy.loopback', 'Loopback') + case 'tailscale': + return translate('auto.lib.remotePairingCopy.tailscale', 'Tailscale address') + case 'lan': + return translate('auto.lib.remotePairingCopy.lan', 'Private LAN address') + case 'public': + return translate('auto.lib.remotePairingCopy.public', 'Public address') + case 'custom': + return translate('auto.lib.remotePairingCopy.custom', 'Custom hostname') + } +} + +export function translateRemotePairingFailureDescription( + kind: RemotePairingFailureKind, + endpoint: string | null +): string { + switch (kind) { + case 'host-identity-mismatch': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.identityMismatchHelp', + 'Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.', + { endpoint: endpoint ?? 'Orca' } + ) + case 'access-link-invalid': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.invalidLinkHelp', + 'Generate a new access link on the other host and try again.' + ) + case 'protocol-incompatible': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.incompatibleHelp', + 'Update Orca on this device and the other host, then try again.' + ) + case 'connection-interrupted': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.interruptedHelp', + 'The connection stopped during verification. Check the network or SSH tunnel and try again.' + ) + case 'environment-save-failed': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.saveFailedHelp', + 'The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.' + ) + case 'host-unreachable': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.unavailableHelp', + 'Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.', + { endpoint: endpoint ?? 'Orca' } + ) + } +} diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a8636b50ea6..6b5b3a093e3 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -5,6 +5,7 @@ import type { PreloadApi } from '../../../preload/api-types' import type { FeatureInteractionState } from '../../../shared/feature-interactions' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../../shared/protocol-version' const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567' @@ -200,6 +201,7 @@ describe('web before-unload persistence', () => { afterEach(() => { vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') }) it('persists final UI and host-partitioned sessions synchronously', async () => { @@ -531,6 +533,187 @@ describe('web runtime environment identity', () => { ) } ) + it('keeps the current host when verification rejects an incompatible replacement', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'status', + ok: true, + result: { runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, + _meta: { runtimeId: 'runtime-old' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const previousStored = globals.storage.getItem('orca.web.runtimeEnvironment.v1') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Incompatible server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ ok: false, kind: 'protocol-incompatible' }) + expect(globals.storage.getItem('orca.web.runtimeEnvironment.v1')).toBe(previousStored) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('keeps the current host when browser storage rejects a verified replacement', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-new', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + }, + _meta: { runtimeId: 'runtime-new' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + vi.spyOn(globals.storage, 'setItem').mockImplementation(() => { + throw new Error('Browser storage is full.') + }) + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Verified replacement', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'environment-save-failed', + message: 'Orca verified the host but could not save it. Check browser storage and try again.' + }) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('requires an explicit loopback override and persists the SSH dependency', async () => { + const call = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-new', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + }, + _meta: { runtimeId: 'runtime-new' } + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const pairingCode = encodePairingCode({ endpoint: 'ws://127.0.0.1:6768' }) + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Tunnel server', + pairingCode + }) + ).resolves.toMatchObject({ ok: false, kind: 'host-unreachable' }) + expect(call).not.toHaveBeenCalled() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Tunnel server', + pairingCode, + allowLoopback: true + }) + ).resolves.toMatchObject({ + ok: true, + environment: { connectionDependency: 'ssh-tunnel' } + }) + expect(call).toHaveBeenCalledOnce() + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ connectionDependency: 'ssh-tunnel' }) + }) + + it('returns a structured failure when the browser client cannot be constructed', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor() { + throw new Error('Invalid public key: expected 32 bytes, got 3') + } + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Broken server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' }) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('classifies coded browser authorization failures without relying on copy', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.reject( + Object.assign(new Error('Access grant rejected.'), { code: 'unauthorized' }) + ) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Expired server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'access-link-invalid', + message: 'Access grant rejected.' + }) + }) }) describe('web browser-local port capability', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7cefb6e4dee..2ae7a4d50c3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -7,6 +7,8 @@ import type { NativeChatAppendedMessages } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { parseHostAccessLink } from '../../../shared/remote-pairing-address' +import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -113,6 +115,7 @@ import { import { parseWebPairingInput } from './web-pairing' import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback' import { WebRuntimeClient } from './web-runtime-client' +import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error' import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' import { assertClipboardTextWriteWithinLimitWithYield, @@ -135,6 +138,7 @@ import { } from '../../../shared/feature-interactions' import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' import { parseRuntimeNativeChatReadSessionResult, @@ -1362,6 +1366,106 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim saveStoredWebRuntimeEnvironment(activeEnvironment) return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } }, + verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { + const parsed = parseHostAccessLink(pairingCode) + if (!parsed.ok) { + return { + ok: false, + kind: 'access-link-invalid', + message: translateHostAccessLinkError(parsed.kind) + } + } + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.loopbackPairingBlocked', + 'This access link points back to this device.' + ) + } + } + let client: WebRuntimeClient | null = null + let runtimeStatus: RuntimeStatus + try { + client = new WebRuntimeClient(parsed.value.pairing) + const response = (await client.call('status.get', undefined, { + timeoutMs: 15_000 + })) as RuntimeRpcResponse + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid public key')) { + return { + ok: false, + kind: 'access-link-invalid', + message: translate( + 'auto.web.webPreloadApi.remotePairingInvalidDetails', + 'This access link contains invalid connection details.' + ) + } + } + if ( + isWebRuntimeUnauthorizedError(error) || + (error instanceof Error && error.message.startsWith('Unauthorized.')) + ) { + return { + ok: false, + kind: 'access-link-invalid', + message: error.message + } + } + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.remotePairingUnreachable', + 'Cannot reach Orca at {{endpoint}}.', + { endpoint: parsed.value.displayEndpoint } + ) + } + } finally { + client?.close() + } + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true + const nextEnvironment = createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }) + // Why: a browser storage failure must leave the currently active host usable. + try { + saveStoredWebRuntimeEnvironment(nextEnvironment) + } catch { + return { + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingSaveFailed', + 'Orca verified the host but could not save it. Check browser storage and try again.' + ) + } + } + manuallyDisconnectedEnvironmentIds.clear() + closeActiveRuntimeClients() + activeEnvironment = nextEnvironment + return { + ok: true, + environment: redactStoredWebRuntimeEnvironment(nextEnvironment), + runtimeStatus + } + }, resolve: async ({ selector }) => redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), remove: async ({ selector }) => { diff --git a/src/renderer/src/web/web-runtime-client-error.ts b/src/renderer/src/web/web-runtime-client-error.ts new file mode 100644 index 00000000000..c09daa02bdc --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-error.ts @@ -0,0 +1,25 @@ +const UNAUTHORIZED_MESSAGE = 'Unauthorized. Pair this web client again.' + +export class WebRuntimeClientError extends Error { + constructor( + message: string, + readonly code: 'unauthorized' + ) { + super(message) + this.name = 'WebRuntimeClientError' + } +} + +export function createWebRuntimeUnauthorizedError(): WebRuntimeClientError { + return new WebRuntimeClientError(UNAUTHORIZED_MESSAGE, 'unauthorized') +} + +export function isWebRuntimeUnauthorizedError( + error: unknown +): error is Error & { code: 'unauthorized' } { + return ( + error instanceof Error && + 'code' in error && + (error as { code?: unknown }).code === 'unauthorized' + ) +} diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 9f61eefe37f..12507f6225f 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -15,6 +15,7 @@ import { publicKeyToBase64 } from './web-e2ee' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' type WebRuntimeConnectionState = | 'disconnected' @@ -478,7 +479,7 @@ export class WebRuntimeClient { } else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') { this.intentionallyClosed = true this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') + this.rejectAllPending(createWebRuntimeUnauthorizedError()) this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') this.ws?.close() } @@ -530,7 +531,7 @@ export class WebRuntimeClient { if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') { this.intentionallyClosed = true this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') + this.rejectAllPending(createWebRuntimeUnauthorizedError()) this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') this.ws?.close() return @@ -584,7 +585,7 @@ export class WebRuntimeClient { return Promise.resolve() } if (this.state === 'auth-failed') { - return Promise.reject(new Error('Unauthorized. Pair this web client again.')) + return Promise.reject(createWebRuntimeUnauthorizedError()) } if (this.intentionallyClosed) { return Promise.reject(new Error('Remote Orca runtime connection closed.')) @@ -658,7 +659,7 @@ export class WebRuntimeClient { waiter.resolve() } } else if (next === 'auth-failed') { - this.rejectAllWaiters(new Error('Unauthorized. Pair this web client again.')) + this.rejectAllWaiters(createWebRuntimeUnauthorizedError()) } } @@ -667,8 +668,8 @@ export class WebRuntimeClient { return `web-rpc-${this.requestCounter}-${Date.now()}` } - private rejectAllPending(reason: string): void { - const error = new Error(reason) + private rejectAllPending(reason: string | Error): void { + const error = typeof reason === 'string' ? new Error(reason) : reason for (const [id, pending] of this.pending) { this.pending.delete(id) window.clearTimeout(pending.timeout) diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 7aa48f5711d..4f97c416960 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -59,6 +59,7 @@ export function createStoredWebRuntimeEnvironment(args: { name: string offer: WebPairingOffer previousEnvironment?: StoredWebRuntimeEnvironment | null + connectionDependency?: 'ssh-tunnel' }): StoredWebRuntimeEnvironment { const id = `web-${createBrowserUuid()}` const now = Date.now() @@ -70,6 +71,7 @@ export function createStoredWebRuntimeEnvironment(args: { updatedAt: now, lastUsedAt: null, runtimeId: null, + ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), preferredEndpointId: `ws-${id}`, endpoints: [ diff --git a/src/shared/filesystem-rename-collision.ts b/src/shared/filesystem-rename-collision.ts index e2674d0e618..6d79cf6f0d7 100644 --- a/src/shared/filesystem-rename-collision.ts +++ b/src/shared/filesystem-rename-collision.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { lstat, realpath } from 'node:fs/promises' import type { Stats } from 'node:fs' import { basename, dirname } from 'node:path' @@ -18,14 +18,33 @@ function hasSameFilesystemIdentity(oldStat: Stats, newStat: Stats): boolean { return oldStat.dev === newStat.dev && oldStat.ino === newStat.ino } -function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean { +async function isSameDirectoryEntryRename( + oldPath: string, + newPath: string, + oldStat: Stats, + newStat: Stats +): Promise { const oldBasename = basename(oldPath) const newBasename = basename(newPath) - return ( - dirname(oldPath) === dirname(newPath) && - oldBasename !== newBasename && - caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) - ) + if (!hasSameFilesystemIdentity(oldStat, newStat) || dirname(oldPath) !== dirname(newPath)) { + return false + } + if (oldPath === newPath) { + return true + } + try { + const [oldRealPath, newRealPath] = await Promise.all([realpath(oldPath), realpath(newPath)]) + return oldRealPath === newRealPath + } catch (error) { + // Preserve case-only renames for dangling symlinks, which realpath cannot resolve. + if (!isENOENT(error)) { + throw error + } + return ( + oldBasename !== newBasename && + caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) + ) + } } export async function assertNoClobberRenameDestinationAvailable( @@ -43,7 +62,7 @@ export async function assertNoClobberRenameDestinationAvailable( } const oldStat = await lstat(oldPath) - if (hasSameFilesystemIdentity(oldStat, newStat) && isCaseOnlySameParentRename(oldPath, newPath)) { + if (await isSameDirectoryEntryRename(oldPath, newPath, oldStat, newStat)) { return } diff --git a/src/shared/network/server-share-address.test.ts b/src/shared/network/server-share-address.test.ts index 933ef09ff75..e7be14d14d3 100644 --- a/src/shared/network/server-share-address.test.ts +++ b/src/shared/network/server-share-address.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits' import { parseServerShareAddress } from './server-share-address' describe('parseServerShareAddress', () => { @@ -16,19 +17,41 @@ describe('parseServerShareAddress', () => { expect(parseServerShareAddress('my-host:443').ok).toBe(true) }) + it('accepts IPv6 literals with an optional port', () => { + expect(parseServerShareAddress('fd7a:115c:a1e0::1').ok).toBe(true) + expect(parseServerShareAddress('[fd7a:115c:a1e0::1]:6768').ok).toBe(true) + }) + it('accepts ws:// and wss:// URLs', () => { expect(parseServerShareAddress('wss://my-host/path').ok).toBe(true) expect(parseServerShareAddress('ws://192.168.1.50:6768').ok).toBe(true) }) + it('rejects WebSocket URLs with fragments', () => { + expect(parseServerShareAddress('wss://my-host/path#fragment').ok).toBe(false) + }) + it('trims surrounding whitespace', () => { expect(parseServerShareAddress(' my-host:8080 ')).toEqual({ ok: true, value: 'my-host:8080' }) }) it('rejects empty, whitespace-containing, and malformed input', () => { - for (const bad of ['', ' ', 'has space', 'http://my-host', 'wss://', ':6768']) { + for (const bad of [ + '', + ' ', + 'has space', + 'http://my-host', + 'wss://', + ':6768', + '0.0.0.0', + '::', + 'my-host:0', + '999.999.999.999', + 'wss://user:password@my-host' + ]) { expect(parseServerShareAddress(bad).ok).toBe(false) } + expect(parseServerShareAddress('a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)).ok).toBe(false) }) it('rejects an out-of-range port', () => { diff --git a/src/shared/network/server-share-address.ts b/src/shared/network/server-share-address.ts index 1afe5f728b7..35b84b9fb1d 100644 --- a/src/shared/network/server-share-address.ts +++ b/src/shared/network/server-share-address.ts @@ -1,42 +1,12 @@ -// Why: shared validator for the "Share this Orca server" custom address. The -// field accepts a bare host, host:port, or a full ws(s):// URL — looser than -// the mobile pairing grammar because the target is a transport endpoint, not -// just an IP. Kept pure so the renderer and any future caller agree. +import { parseManualNetworkAddress } from './manual-address' export type ParseServerShareAddressResult = { ok: true; value: string } | { ok: false } -const HOST_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?' -const HOSTNAME = `${HOST_LABEL}(?:\\.${HOST_LABEL})*` -const IPV4 = '(?:\\d{1,3}\\.){3}\\d{1,3}' -const HOST = `(?:${HOSTNAME}|${IPV4})` -const PORT = '[0-9]{1,5}' - -const HOST_OR_HOST_PORT = new RegExp(`^${HOST}(?::${PORT})?$`) - export function parseServerShareAddress(input: string): ParseServerShareAddressResult { const trimmed = input.trim() - if (trimmed === '' || /\s/.test(trimmed)) { + if (/^https?:\/\//i.test(trimmed)) { return { ok: false } } - - // Full ws(s):// URL — defer to the URL parser, which validates host/port/path. - if (/^wss?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed) - return url.hostname !== '' ? { ok: true, value: trimmed } : { ok: false } - } catch { - return { ok: false } - } - } - - // Bare host or host:port. Reject an out-of-range port early. - const match = trimmed.match(HOST_OR_HOST_PORT) - if (!match) { - return { ok: false } - } - const portPart = trimmed.includes(':') ? trimmed.slice(trimmed.lastIndexOf(':') + 1) : null - if (portPart !== null && Number(portPart) > 65535) { - return { ok: false } - } - return { ok: true, value: trimmed } + const result = parseManualNetworkAddress(trimmed) + return result.ok ? { ok: true, value: result.address } : { ok: false } } diff --git a/src/shared/orchestration-run-pagination.ts b/src/shared/orchestration-run-pagination.ts new file mode 100644 index 00000000000..6035a9e19a5 --- /dev/null +++ b/src/shared/orchestration-run-pagination.ts @@ -0,0 +1 @@ +export const ORCHESTRATION_RUN_PAGE_LIMIT = 100 diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index ff9eaed0c81..1d1879587be 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { encodePairingOffer, decodePairingOffer, @@ -22,6 +22,19 @@ describe('pairing offer', () => { expect(decoded).toEqual(offer) }) + it('decodes in browser runtimes without Buffer', () => { + const url = encodePairingOffer(offer) + const nodeBuffer = Buffer + let decoded: PairingOffer | null = null + try { + vi.stubGlobal('Buffer', undefined) + decoded = decodePairingOffer(url) + } finally { + vi.stubGlobal('Buffer', nodeBuffer) + } + expect(decoded).toEqual(offer) + }) + it('preserves optional device scope metadata', () => { const scopedOffer: PairingOffer = { ...offer, scope: 'mobile' } expect(decodePairingOffer(encodePairingOffer(scopedOffer))).toEqual(scopedOffer) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 00a3e7e0fdd..3c8335162a7 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -89,6 +89,11 @@ function decodePairingBase64(base64url: string): PairingOffer { throw new Error('Invalid pairing code') } const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - const json = Buffer.from(base64, 'base64').toString('utf-8') + const json = + typeof Buffer === 'undefined' + ? new TextDecoder().decode( + Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)) + ) + : Buffer.from(base64, 'base64').toString('utf-8') return PairingOfferSchema.parse(JSON.parse(json)) } diff --git a/src/shared/remote-pairing-address.test.ts b/src/shared/remote-pairing-address.test.ts new file mode 100644 index 00000000000..0b38be5a985 --- /dev/null +++ b/src/shared/remote-pairing-address.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing' +import { classifyRemotePairingHostname, parseHostAccessLink } from './remote-pairing-address' + +function accessLink(endpoint: string): string { + return encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'runtime' + }) +} + +describe('remote pairing address', () => { + it.each([ + ['127.0.0.1', 'loopback'], + ['localhost', 'loopback'], + ['localhost.', 'loopback'], + ['api.localhost', 'loopback'], + ['api.localhost.', 'loopback'], + ['localhost.localdomain', 'loopback'], + ['localhost6', 'loopback'], + ['ip6-localhost', 'loopback'], + ['::1', 'loopback'], + ['::ffff:7f00:1', 'loopback'], + ['100.76.32.125', 'tailscale'], + ['::ffff:644c:207d', 'tailscale'], + ['192.168.1.20', 'lan'], + ['10.0.0.8', 'lan'], + ['fd7a:115c:a1e0::1', 'lan'], + ['fe80::1', 'lan'], + ['orca.example.com', 'public'], + ['devbox', 'custom'] + ] as const)('classifies %s as %s', (hostname, expected) => { + expect(classifyRemotePairingHostname(hostname)).toBe(expected) + }) + + it('extracts a sanitized display endpoint without credentials', () => { + expect(parseHostAccessLink(accessLink('wss://orca.example.com/runtime'))).toEqual({ + ok: true, + value: { + pairing: expect.objectContaining({ endpoint: 'wss://orca.example.com/runtime' }), + displayEndpoint: 'orca.example.com', + endpointKind: 'public' + } + }) + }) + + it('keeps IPv6 brackets in the display endpoint', () => { + const result = parseHostAccessLink(accessLink('ws://[fd7a:115c:a1e0::1]:6768')) + expect(result.ok && result.value.displayEndpoint).toBe('[fd7a:115c:a1e0::1]:6768') + }) + + it('rejects invalid and unsupported endpoints', () => { + expect(parseHostAccessLink('not-a-link')).toMatchObject({ + ok: false, + kind: 'invalid-input' + }) + expect(parseHostAccessLink(accessLink('https://orca.example.com'))).toMatchObject({ + ok: false, + kind: 'unsupported-destination' + }) + expect(parseHostAccessLink(accessLink('wss://orca.example.com/#fragment'))).toMatchObject({ + ok: false, + kind: 'unsupported-destination' + }) + expect(parseHostAccessLink(accessLink('ws://[::ffff:0.0.0.0]:6768'))).toMatchObject({ + ok: false, + kind: 'non-connectable-destination' + }) + expect(parseHostAccessLink(accessLink('wss://orca.example.com:0'))).toMatchObject({ + ok: false, + kind: 'non-connectable-destination' + }) + }) + + it('blocks absolute localhost names used in access links', () => { + expect(parseHostAccessLink(accessLink('ws://localhost.:6768'))).toMatchObject({ + ok: true, + value: { endpointKind: 'loopback' } + }) + expect(parseHostAccessLink(accessLink('ws://api.localhost.:6768'))).toMatchObject({ + ok: true, + value: { endpointKind: 'loopback' } + }) + }) + + it('rejects mobile-only access grants', () => { + const link = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'wss://orca.example.com', + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'mobile' + }) + expect(parseHostAccessLink(link)).toMatchObject({ ok: false, kind: 'mobile-only' }) + }) +}) diff --git a/src/shared/remote-pairing-address.ts b/src/shared/remote-pairing-address.ts new file mode 100644 index 00000000000..50b3241272d --- /dev/null +++ b/src/shared/remote-pairing-address.ts @@ -0,0 +1,147 @@ +import { parsePairingCode, type PairingOffer } from './pairing' +import { isTailnetIPv4Address } from './tailnet-address' + +export type RemotePairingEndpointKind = 'loopback' | 'tailscale' | 'lan' | 'public' | 'custom' + +export type ParsedHostAccessLink = { + pairing: PairingOffer + displayEndpoint: string + endpointKind: RemotePairingEndpointKind +} + +export type HostAccessLinkErrorKind = + | 'invalid-input' + | 'mobile-only' + | 'invalid-destination' + | 'unsupported-destination' + | 'non-connectable-destination' + +export type ParseHostAccessLinkResult = + | { ok: true; value: ParsedHostAccessLink } + | { ok: false; kind: HostAccessLinkErrorKind; message: string } + +const LOOPBACK_HOSTS = new Set([ + 'localhost', + 'localhost.localdomain', + 'localhost6', + 'localhost6.localdomain6', + 'ip6-localhost', + 'ip6-loopback', + '127.0.0.1', + '::1' +]) + +function isPrivateIPv4Address(hostname: string): boolean { + const octets = hostname.split('.').map(Number) + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) { + return false + } + return ( + octets[0] === 10 || + (octets[0] === 172 && octets[1]! >= 16 && octets[1]! <= 31) || + (octets[0] === 192 && octets[1] === 168) + ) +} + +function isPrivateIPv6Address(hostname: string): boolean { + const firstHextet = Number.parseInt(hostname.split(':')[0] ?? '', 16) + return ( + Number.isInteger(firstHextet) && + ((firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80) + ) +} + +function getEmbeddedIPv4Address(hostname: string): string | null { + const match = hostname.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i) + if (!match) { + return null + } + const high = Number.parseInt(match[1]!, 16) + const low = Number.parseInt(match[2]!, 16) + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}` +} + +export function classifyRemotePairingHostname(hostname: string): RemotePairingEndpointKind { + const normalized = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') + const embeddedIPv4 = getEmbeddedIPv4Address(normalized) + if (embeddedIPv4) { + return classifyRemotePairingHostname(embeddedIPv4) + } + if ( + LOOPBACK_HOSTS.has(normalized) || + normalized.endsWith('.localhost') || + normalized.startsWith('127.') + ) { + return 'loopback' + } + if (isTailnetIPv4Address(normalized)) { + return 'tailscale' + } + if (isPrivateIPv4Address(normalized) || isPrivateIPv6Address(normalized)) { + return 'lan' + } + return normalized.includes('.') || normalized.includes(':') ? 'public' : 'custom' +} + +export function parseHostAccessLink(input: string): ParseHostAccessLinkResult { + const pairing = parsePairingCode(input) + if (!pairing) { + return { + ok: false, + kind: 'invalid-input', + message: 'Enter an Orca access link or bare pairing code.' + } + } + if (pairing.scope === 'mobile') { + return { + ok: false, + kind: 'mobile-only', + message: 'This link grants mobile-only access. Generate a link for another Orca client.' + } + } + let endpoint: URL + try { + endpoint = new URL(pairing.endpoint) + } catch { + return { + ok: false, + kind: 'invalid-destination', + message: 'This access link contains an invalid destination.' + } + } + if ( + (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || + !endpoint.hostname || + endpoint.hash !== '' + ) { + return { + ok: false, + kind: 'unsupported-destination', + message: 'This access link contains an unsupported destination.' + } + } + const normalizedHostname = endpoint.hostname.toLowerCase().replace(/^\[|\]$/g, '') + if ( + normalizedHostname === '0.0.0.0' || + normalizedHostname === '::' || + getEmbeddedIPv4Address(normalizedHostname) === '0.0.0.0' || + endpoint.port === '0' + ) { + return { + ok: false, + kind: 'non-connectable-destination', + message: 'This access link contains a non-connectable destination.' + } + } + return { + ok: true, + value: { + pairing, + displayEndpoint: endpoint.host, + endpointKind: classifyRemotePairingHostname(endpoint.hostname) + } + } +} diff --git a/src/shared/remote-pairing-verification.test.ts b/src/shared/remote-pairing-verification.test.ts new file mode 100644 index 00000000000..8fae1dfb82d --- /dev/null +++ b/src/shared/remote-pairing-verification.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import { verifyRemotePairingRuntimeStatus } from './remote-pairing-verification' + +function runtimeStatus(overrides: Record = {}): Record { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + ...overrides + } +} + +describe('verifyRemotePairingRuntimeStatus', () => { + it('rejects malformed status responses', () => { + expect(verifyRemotePairingRuntimeStatus(null)).toMatchObject({ + ok: false, + kind: 'connection-interrupted' + }) + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + runtimeProtocolVersion: Number.NaN + }) + ) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect( + verifyRemotePairingRuntimeStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION }) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect( + verifyRemotePairingRuntimeStatus(runtimeStatus({ capabilities: 'runtime.status.compat.v1' })) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect(verifyRemotePairingRuntimeStatus(runtimeStatus({ deviceScope: 'admin' }))).toMatchObject( + { + ok: false, + kind: 'connection-interrupted' + } + ) + }) + + it('rejects mobile-only access grants', () => { + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + deviceScope: 'mobile' + }) + ) + ).toMatchObject({ ok: false, kind: 'access-link-invalid' }) + }) + + it('rejects incompatible hosts', () => { + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 + }) + ) + ).toMatchObject({ ok: false, kind: 'protocol-incompatible' }) + }) + + it('accepts a compatible runtime status', () => { + expect(verifyRemotePairingRuntimeStatus(runtimeStatus())).toMatchObject({ ok: true }) + }) +}) diff --git a/src/shared/remote-pairing-verification.ts b/src/shared/remote-pairing-verification.ts new file mode 100644 index 00000000000..02d58b97bed --- /dev/null +++ b/src/shared/remote-pairing-verification.ts @@ -0,0 +1,114 @@ +import { evaluateRuntimeCompat } from './protocol-compat' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import type { PublicKnownRuntimeEnvironment } from './runtime-environments' +import type { RuntimeStatus } from './runtime-types' + +export type RemotePairingFailureKind = + | 'host-unreachable' + | 'host-identity-mismatch' + | 'access-link-invalid' + | 'protocol-incompatible' + | 'connection-interrupted' + | 'environment-save-failed' + +export type RemotePairingFailure = { + ok: false + kind: RemotePairingFailureKind + message: string +} + +export type VerifyAndAddRuntimeEnvironmentResult = + | { + ok: true + environment: PublicKnownRuntimeEnvironment + runtimeStatus: RuntimeStatus + } + | RemotePairingFailure + +const RUNTIME_GRAPH_STATUSES = new Set(['ready', 'reloading', 'unavailable']) + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} + +function hasValidRuntimeStatusShape(status: Record): boolean { + return ( + typeof status.runtimeId === 'string' && + status.runtimeId.length > 0 && + isNonNegativeSafeInteger(status.rendererGraphEpoch) && + typeof status.graphStatus === 'string' && + RUNTIME_GRAPH_STATUSES.has(status.graphStatus) && + (status.authoritativeWindowId === null || + isNonNegativeSafeInteger(status.authoritativeWindowId)) && + isNonNegativeSafeInteger(status.liveTabCount) && + isNonNegativeSafeInteger(status.liveLeafCount) && + (status.deviceScope === undefined || + status.deviceScope === 'mobile' || + status.deviceScope === 'runtime') && + (status.capabilities === undefined || + (Array.isArray(status.capabilities) && + status.capabilities.every((capability) => typeof capability === 'string'))) + ) +} + +export function verifyRemotePairingRuntimeStatus( + value: unknown +): { ok: true; runtimeStatus: RuntimeStatus } | RemotePairingFailure { + if (typeof value !== 'object' || value === null) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid status response.' + } + } + const status = value as Partial & Record + if (status.deviceScope === 'mobile') { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This link grants mobile-only access. Generate a link for another Orca client.' + } + } + const versionFields = [ + status.runtimeProtocolVersion, + status.protocolVersion, + status.minCompatibleRuntimeClientVersion, + status.minCompatibleMobileVersion + ] + if ( + versionFields.some( + (version) => version !== undefined && (!Number.isSafeInteger(version) || Number(version) < 0) + ) + ) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid protocol version.' + } + } + const compatibility = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (compatibility.kind === 'blocked') { + return { + ok: false, + kind: 'protocol-incompatible', + message: + compatibility.reason === 'client-too-old' + ? 'Update this Orca client before adding the remote host.' + : 'Update Orca on the remote host before adding it.' + } + } + if (!hasValidRuntimeStatusShape(status)) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid status response.' + } + } + return { ok: true, runtimeStatus: value as RuntimeStatus } +} diff --git a/src/shared/remote-runtime-client-error.ts b/src/shared/remote-runtime-client-error.ts index b6429620c04..487a1108b87 100644 --- a/src/shared/remote-runtime-client-error.ts +++ b/src/shared/remote-runtime-client-error.ts @@ -1,3 +1,5 @@ +export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-grant' | 'runtime' + /** * Error type for the remote-runtime client, split out from * `remote-runtime-client.ts` so type-only consumers can reference it without @@ -7,10 +9,21 @@ */ export class RemoteRuntimeClientError extends Error { readonly code: string + readonly pairingStage?: RemoteRuntimePairingStage + readonly closeCode?: number - constructor(code: string, message: string) { + constructor( + code: string, + message: string, + details?: { + pairingStage?: RemoteRuntimePairingStage + closeCode?: number + } + ) { super(message) this.name = 'RemoteRuntimeClientError' this.code = code + this.pairingStage = details?.pairingStage + this.closeCode = details?.closeCode } } diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d16b3e34807..43a2a982d73 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -197,6 +197,28 @@ describe('sendRemoteRuntimeRequest', () => { ) }) + it('classifies a non-Orca handshake as a host identity mismatch', async () => { + const server = await createInvalidHandshakeServer() + + await expect( + sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000) + ).rejects.toMatchObject({ + code: 'invalid_runtime_response', + pairingStage: 'host-identity' + }) + }) + + it('classifies an undecryptable post-auth frame as a runtime failure', async () => { + const server = await createOneShotServer({ sendUndecryptableResponse: true }) + + await expect( + sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000) + ).rejects.toMatchObject({ + code: 'invalid_runtime_response', + pairingStage: 'runtime' + }) + }) + it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => { const server = await createOneShotServer() @@ -425,10 +447,35 @@ async function createClosingServer( return { pairing } } +async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> { + const serverKeyPair = generateKeyPair() + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + wss.on('connection', (ws) => { + ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' }))) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + return { pairing } +} + async function createOneShotServer( options: { response?: (requestId: string) => unknown onRequest?: (request: Record) => void + sendUndecryptableResponse?: boolean } = {} ): Promise<{ pairing: PairingOffer }> { const serverKeyPair = generateKeyPair() @@ -466,6 +513,10 @@ async function createOneShotServer( const request = JSON.parse(plaintext) as { id: string } & Record options.onRequest?.(request) + if (options.sendUndecryptableResponse) { + ws.send('not-an-encrypted-frame') + return + } const key = sharedKey const keepalive = setInterval(() => { sendEncrypted(ws, key, { _keepalive: true }) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index dafa2261feb..8cc5c14046e 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -121,6 +121,12 @@ export async function sendRemoteRuntimeRequest( let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null + const getPairingStage = (): 'connect' | 'host-identity' | 'runtime' => + state === 'awaiting_ready' + ? 'connect' + : state === 'awaiting_authenticated' + ? 'host-identity' + : 'runtime' const cleanupSocketListeners = (): void => { const socket = ws @@ -145,7 +151,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'runtime_timeout', - 'Timed out waiting for the remote Orca runtime to respond.' + 'Timed out waiting for the remote Orca runtime to respond.', + { pairingStage: getPairingStage() } ) }) } @@ -210,7 +217,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'remote_runtime_unavailable', - 'Could not connect to the remote Orca runtime.' + 'Could not connect to the remote Orca runtime.', + { pairingStage: getPairingStage() } ) }) } @@ -221,7 +229,11 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'remote_runtime_unavailable', - formatRemoteRuntimeCloseMessage(code, reason) + formatRemoteRuntimeCloseMessage(code, reason), + { + pairingStage: getPairingStage(), + closeCode: code + } ) }) } @@ -236,7 +248,10 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an unexpected binary frame.' + 'Remote Orca runtime returned an unexpected binary frame.', + { + pairingStage: state === 'awaiting_ready' ? 'host-identity' : getPairingStage() + } ) }) return @@ -254,7 +269,10 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an undecryptable frame.' + 'Remote Orca runtime returned an undecryptable frame.', + { + pairingStage: state === 'awaiting_authenticated' ? 'host-identity' : getPairingStage() + } ) }) return @@ -282,7 +300,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid E2EE handshake frame.' + 'Remote Orca runtime returned an invalid E2EE handshake frame.', + { pairingStage: 'host-identity' } ) }) return @@ -296,7 +315,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + 'Remote Orca runtime returned an unexpected E2EE handshake frame.', + { pairingStage: 'host-identity' } ) }) return @@ -314,7 +334,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid E2EE auth frame.' + 'Remote Orca runtime returned an invalid E2EE auth frame.', + { pairingStage: 'host-identity' } ) }) return @@ -331,7 +352,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( code, - 'Remote Orca runtime rejected the pairing token.' + 'Remote Orca runtime rejected the pairing token.', + { pairingStage: code === 'unauthorized' ? 'access-grant' : 'host-identity' } ) }) return @@ -361,7 +383,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid response frame.' + 'Remote Orca runtime returned an invalid response frame.', + { pairingStage: 'runtime' } ) }) return @@ -376,7 +399,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid response frame.' + 'Remote Orca runtime returned an invalid response frame.', + { pairingStage: 'runtime' } ) }) return @@ -387,7 +411,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned a mismatched response id.' + 'Remote Orca runtime returned a mismatched response id.', + { pairingStage: 'runtime' } ) }) return diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index f58093337fa..3e34f5846b2 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -87,6 +87,29 @@ describe('runtime environment store', () => { ]).toEqual([101, 102, 200]) }) + it('keeps SSH-tunnel metadata only while the pairing endpoint is loopback', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + const environment = addEnvironmentFromPairingCode(userDataPath, { + name: 'tunneled box', + pairingCode: pairingCode(), + connectionDependency: 'ssh-tunnel' + }) + expect(environment.connectionDependency).toBe('ssh-tunnel') + + const updated = updateEnvironmentFromPairingCode(userDataPath, environment.id, { + pairingCode: pairingCode('ws://192.0.2.10:6768') + }) + expect(updated).not.toHaveProperty('connectionDependency') + + const direct = addEnvironmentFromPairingCode(userDataPath, { + name: 'direct box', + pairingCode: pairingCode('ws://192.0.2.11:6768'), + connectionDependency: 'ssh-tunnel' + }) + expect(direct).not.toHaveProperty('connectionDependency') + }) + it('throttles lastUsedAt writes so it does not rewrite the store on every runtime call', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) tempDirs.push(userDataPath) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 4211eb18da8..85f90f8bef6 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' +import { classifyRemotePairingHostname } from './remote-pairing-address' import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' import { hardenExistingSecureFile } from './secure-file' import { @@ -41,7 +42,13 @@ export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[ export function addEnvironmentFromPairingCode( userDataPath: string, - args: { name: string; pairingCode: string; now?: number; source?: RuntimeEnvironmentSource } + args: { + name: string + pairingCode: string + now?: number + source?: RuntimeEnvironmentSource + connectionDependency?: 'ssh-tunnel' + } ): KnownRuntimeEnvironment { const offer = parsePairingCode(args.pairingCode) if (!offer) { @@ -65,7 +72,8 @@ export function addEnvironmentFromPairingCode( now, offer, runtimeId: null, - ...(args.source ? { source: args.source } : {}) + ...(args.source ? { source: args.source } : {}), + ...getPairingConnectionDependency(args.connectionDependency, offer) }) const next = { version: 1 as const, @@ -110,7 +118,8 @@ export function updateEnvironmentFromPairingCode( now: existing.createdAt, offer, runtimeId: existing.runtimeId, - ...(existing.source ? { source: existing.source } : {}) + ...(existing.source ? { source: existing.source } : {}), + ...getPairingConnectionDependency(existing.connectionDependency, offer) }) const next = { ...environment, @@ -128,6 +137,23 @@ export function updateEnvironmentFromPairingCode( return next } +function getPairingConnectionDependency( + dependency: 'ssh-tunnel' | undefined, + offer: PairingOffer +): { connectionDependency?: 'ssh-tunnel' } { + if (!dependency) { + return {} + } + try { + const endpoint = new URL(offer.endpoint) + return classifyRemotePairingHostname(endpoint.hostname) === 'loopback' + ? { connectionDependency: dependency } + : {} + } catch { + return {} + } +} + export function resolveEnvironment( userDataPath: string, selector: string diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index dd3e3b357ce..131414631c2 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -31,6 +31,7 @@ export const KnownRuntimeEnvironmentSchema = z.object({ lastUsedAt: z.number().finite().nullable(), runtimeId: z.string().min(1).nullable(), source: RuntimeEnvironmentSourceSchema.optional(), + connectionDependency: z.literal('ssh-tunnel').optional(), endpoints: z.array(RuntimeAccessEndpointSchema).min(1), preferredEndpointId: z.string().min(1) }) @@ -66,6 +67,7 @@ export function createEnvironmentFromPairingOffer(args: { offer: PairingOffer runtimeId?: string | null source?: RuntimeEnvironmentSource + connectionDependency?: 'ssh-tunnel' }): KnownRuntimeEnvironment { const endpointId = `ws-${args.id}` return KnownRuntimeEnvironmentSchema.parse({ @@ -77,6 +79,7 @@ export function createEnvironmentFromPairingOffer(args: { lastUsedAt: null, runtimeId: args.runtimeId ?? null, ...(args.source ? { source: args.source } : {}), + ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), endpoints: [ { id: endpointId, diff --git a/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts new file mode 100644 index 00000000000..1ba01d0a59c --- /dev/null +++ b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts @@ -0,0 +1,75 @@ +import path from 'node:path' +import { expect, test } from './helpers/orca-app' + +test.describe('floating Markdown filesystem aliases', () => { + test.skip(process.platform !== 'darwin', 'Requires native APFS alias behavior') + + test('renames one APFS entry through its Unicode alias', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalPath = path.join(directory, `floating-alias-${suffix}-straße.md`) + const renamedPath = path.join(directory, `floating-alias-${suffix}-STRASSE.MD`) + const renamedName = path.basename(renamedPath) + + const result = await orcaPage.evaluate( + async ({ directory, originalPath, renamedPath, renamedName }) => { + await window.api.fs.createFile({ filePath: originalPath }) + await window.api.fs.writeFile({ filePath: originalPath, content: 'same entry\n' }) + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: originalPath, newPath: renamedPath }) + ]) + return { + status: settled[0].status, + reason: settled[0].status === 'rejected' ? String(settled[0].reason) : null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + renamedEntryExists: (await window.api.fs.readDir({ dirPath: directory })).some( + ({ name }) => name === renamedName + ) + } + }, + { directory, originalPath, renamedPath, renamedName } + ) + + expect(result).toEqual({ + status: 'fulfilled', + reason: null, + content: 'same entry\n', + renamedEntryExists: true + }) + }) + + test('keeps dotless and ASCII I destinations distinct through IPC', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-dotless-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-dotless-second-${suffix}.md`) + const dotlessDestination = path.join(directory, `floating-destination-${suffix}-ı.md`) + const asciiDestination = path.join(directory, `floating-destination-${suffix}-I.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, dotlessDestination, asciiDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'dotless\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'ascii\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: dotlessDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: asciiDestination }) + ]) + return { + statuses: settled.map(({ status }) => status), + dotlessContent: (await window.api.fs.readFile({ filePath: dotlessDestination })).content, + asciiContent: (await window.api.fs.readFile({ filePath: asciiDestination })).content + } + }, + { firstPath, secondPath, dotlessDestination, asciiDestination } + ) + + expect(result).toEqual({ + statuses: ['fulfilled', 'fulfilled'], + dotlessContent: 'dotless\n', + asciiContent: 'ascii\n' + }) + }) +}) diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts new file mode 100644 index 00000000000..7f9dca4b7b9 --- /dev/null +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -0,0 +1,295 @@ +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// E2E specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const OPEN_PANEL_SELECTOR = '[data-floating-terminal-panel][aria-hidden="false"]' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' + +async function seedFloatingMarkdownFile(page: Page): Promise<{ + originalName: string + originalPath: string + intermediateName: string + intermediatePath: string + renamedName: string + renamedPath: string + tabId: string +}> { + const directory = await page.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalName = `floating-rename-${suffix}.md` + const intermediateName = `floating-entered-${suffix}.md` + const renamedName = `floating-renamed-${suffix}.md` + const originalPath = path.join(directory, originalName) + const intermediatePath = path.join(directory, intermediateName) + const renamedPath = path.join(directory, renamedName) + const tabId = await page.evaluate( + async ({ filePath, originalName, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + + await store.getState().updateSettings({ floatingTerminalEnabled: true }) + await window.api.fs.createFile({ filePath }) + await window.api.fs.writeFile({ filePath, content: '# Floating rename\n' }) + store.getState().openFile( + { + filePath, + relativePath: originalName, + worktreeId, + language: 'markdown', + mode: 'edit', + runtimeEnvironmentId: null + }, + { preview: false, suppressActiveRuntimeFallback: true } + ) + + const state = store.getState() + const file = state.openFiles.find( + (candidate) => candidate.filePath === filePath && candidate.worktreeId === worktreeId + ) + const tab = state.unifiedTabsByWorktree[worktreeId]?.find( + (candidate) => candidate.contentType === 'editor' && candidate.entityId === file?.id + ) + if (!file || !tab) { + throw new Error('Floating Markdown tab unavailable') + } + return tab.id + }, + { filePath: originalPath, originalName, worktreeId: FLOATING_WORKTREE_ID } + ) + return { + originalName, + originalPath, + intermediateName, + intermediatePath, + renamedName, + renamedPath, + tabId + } +} + +async function openFloatingPanel(page: Page): Promise { + await page.waitForFunction( + (selector) => Boolean(document.querySelector(selector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible() +} + +test('concurrent floating Markdown renames do not clobber the destination', async ({ + orcaPage +}) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-second-${suffix}.md`) + const destinationPath = path.join(directory, `floating-destination-${suffix}.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, destinationPath }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: destinationPath }), + window.api.fs.rename({ oldPath: secondPath, newPath: destinationPath }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + rejectionMessages: settled.flatMap((outcome) => + outcome.status === 'rejected' ? [String(outcome.reason)] : [] + ), + destinationContent: (await window.api.fs.readFile({ filePath: destinationPath })).content, + firstExists, + secondExists, + firstContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : null, + secondContent: secondExists + ? (await window.api.fs.readFile({ filePath: secondPath })).content + : null + } + }, + { firstPath, secondPath, destinationPath } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(result.rejectionMessages[0]).toContain('already exists') + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect( + [result.destinationContent, result.firstContent ?? result.secondContent].toSorted() + ).toEqual(['first\n', 'second\n']) +}) + +test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { + test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-unicode-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-unicode-second-${suffix}.md`) + const sharpSDestination = path.join(directory, `floating-destination-${suffix}-straße.md`) + const expandedDestination = path.join(directory, `floating-destination-${suffix}-STRASSE.MD`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, sharpSDestination, expandedDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: sharpSDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: expandedDestination }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + destinationContent: (await window.api.fs.readFile({ filePath: sharpSDestination })).content, + firstExists, + secondExists, + remainingContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : (await window.api.fs.readFile({ filePath: secondPath })).content + } + }, + { firstPath, secondPath, sharpSDestination, expandedDestination } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect([result.destinationContent, result.remainingContent].toSorted()).toEqual([ + 'first\n', + 'second\n' + ]) +}) + +test('floating workspace Markdown renames survive an app restart', async (// oxlint-disable-next-line no-empty-pattern -- This persistence test owns both Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + const seeded = await seedFloatingMarkdownFile(first.page) + await openFloatingPanel(first.page) + + const panel = first.page.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await expect(tab).toContainText(seeded.originalName) + await tab.click({ button: 'right' }) + const renameMenuItem = first.page.getByRole('menuitem').filter({ hasText: 'Rename' }).first() + await expect(renameMenuItem).toBeVisible() + await renameMenuItem.click() + + const enterInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await enterInput.fill(seeded.intermediateName) + await enterInput.press('Enter') + await expect(tab).toContainText(seeded.intermediateName) + + await tab.getByText(seeded.intermediateName, { exact: true }).dispatchEvent('dblclick') + const blurInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.intermediateName}`, + exact: true + }) + await blurInput.fill(seeded.renamedName) + await panel.getByRole('radio', { name: 'Rich Editor' }).click() + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + first.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath }) => ({ + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + }), + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath + } + ) + ) + .toEqual({ + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await openFloatingPanel(second.page) + + const restoredPanel = second.page.locator(OPEN_PANEL_SELECTOR) + const restoredTab = restoredPanel.locator('[data-tab-id]').filter({ + hasText: seeded.renamedName + }) + await expect(restoredTab).toContainText(seeded.renamedName) + await expect + .poll(() => + second.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath, worktreeId }) => { + const file = window.__store + ?.getState() + .openFiles.find( + (candidate) => + candidate.worktreeId === worktreeId && candidate.filePath === renamedPath + ) + return { + restoredPath: file?.filePath ?? null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + } + }, + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath, + worktreeId: FLOATING_WORKTREE_ID + } + ) + ) + .toEqual({ + restoredPath: seeded.renamedPath, + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + } finally { + for (const app of [secondApp, firstApp]) { + if (!app) { + continue + } + try { + await session.close(app) + } catch { + // best-effort cleanup + } + } + await session.dispose() + } +}) diff --git a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts index 5d17f1c0712..a5d4b4631ec 100644 --- a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts @@ -23,6 +23,7 @@ import { } from '../../src/main/runtime/orchestration/db' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' +import { listAllOrchestrationRuns } from './orchestration-run-pages' const PROVIDER_SESSION_ID = 'e2e-legacy-orchestration-worker' const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-legacy-worker-')) @@ -622,10 +623,8 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION let assignmentRunId = run.result.run.id if (contractVersion === LEGACY_CONTRACT_VERSION) { - const runs = await secondClient.call<{ - runs: { id: string; objective: string }[] - }>('orchestration.runList') - assignmentRunId = runs.result.runs.find( + const runs = await listAllOrchestrationRuns(secondClient) + assignmentRunId = runs.find( (candidate) => candidate.objective === 'Recovered orchestration work from a contract update' )!.id diff --git a/tests/e2e/orchestration-run-pages.ts b/tests/e2e/orchestration-run-pages.ts new file mode 100644 index 00000000000..437066526f0 --- /dev/null +++ b/tests/e2e/orchestration-run-pages.ts @@ -0,0 +1,29 @@ +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../src/shared/orchestration-run-pagination' + +export type OrchestrationRunSummary = { + id: string + objective: string +} + +type RunListClient = { + call(method: string, params?: unknown): Promise<{ result: TResult }> +} + +export async function listAllOrchestrationRuns( + client: RunListClient +): Promise { + const runs: OrchestrationRunSummary[] = [] + let cursor: string | undefined + do { + const page = await client.call<{ + runs: OrchestrationRunSummary[] + nextCursor?: string | null + }>('orchestration.runList', { + limit: ORCHESTRATION_RUN_PAGE_LIMIT, + ...(cursor ? { cursor } : {}) + }) + runs.push(...page.result.runs) + cursor = page.result.nextCursor ?? undefined + } while (cursor) + return runs +} diff --git a/tests/e2e/orchestration-run-pagination.unit.test.ts b/tests/e2e/orchestration-run-pagination.unit.test.ts new file mode 100644 index 00000000000..81a261c3120 --- /dev/null +++ b/tests/e2e/orchestration-run-pagination.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest' +import { listAllOrchestrationRuns } from './orchestration-run-pages' + +describe('orchestration Run pagination compatibility', () => { + it('stops after an old server response without nextCursor', async () => { + const call = vi.fn().mockResolvedValue({ + result: { runs: [{ id: 'run_old', objective: 'Old server Run' }] } + }) + + await expect(listAllOrchestrationRuns({ call })).resolves.toEqual([ + { id: 'run_old', objective: 'Old server Run' } + ]) + expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith('orchestration.runList', { limit: 100 }) + }) +})