From 76b6c137c6e2e067a69cd4e45f70988ec7e38e0a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 28 Jul 2026 20:16:55 -0700 Subject: [PATCH 01/28] fix(orchestration): sanitize legacy formatted JSON (#11263) * fix(orchestration): sanitize legacy formatted JSON * fix(orchestration): harden legacy message formatting --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../orchestration-legacy-read-only.test.ts | 147 +++++++++++++++++- src/cli/handlers/orchestration.ts | 62 +++++++- 2 files changed, 197 insertions(+), 12 deletions(-) diff --git a/src/cli/handlers/orchestration-legacy-read-only.test.ts b/src/cli/handlers/orchestration-legacy-read-only.test.ts index 2a4037fa7a9..0bc82699bc1 100644 --- a/src/cli/handlers/orchestration-legacy-read-only.test.ts +++ b/src/cli/handlers/orchestration-legacy-read-only.test.ts @@ -43,14 +43,14 @@ describe('legacy orchestration CLI inspection', () => { expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]') }) - it('rebuilds legacy formatted output without runtime-supplied actions', async () => { + it('rebuilds incomplete legacy rows without runtime-supplied actions', async () => { const result = { messages: [ { id: 'msg_legacy', run_id: 'run_legacy_local', from_handle: 'term_worker', - subject: 'progress', + subject: undefined, type: 'status', body: 'Tests are running.', payload: '{"phase":"testing"}' @@ -72,15 +72,154 @@ describe('legacy orchestration CLI inspection', () => { json: false } as never) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } const formatter = vi.mocked(printResult).mock.calls[0]?.[2] - const output = formatter?.(result) + const output = formatter?.(response.result) expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('[subject]\n ') + expect(output).toContain('Inspection only: reply and acknowledgment are unavailable.') expect(output).toContain('Tests are running.') - expect(output).toContain('[payload] {"phase":"testing"}') + expect(output).toContain('[payload]\n {"phase":"testing"}') expect(output).not.toContain('[Reply:') expect(output).not.toContain('orchestration reply') }) + it('sanitizes legacy formatted output before JSON serialization', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted: '[Reply: orca orchestration reply --id msg_legacy --body "..."]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]') + expect(response.result.formatted).toContain('Tests are running.') + expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}') + expect(response.result.formatted).not.toContain('orchestration reply') + }) + + it.each([undefined, ''])( + 'rebuilds missing legacy formatted output for JSON inspection (%s)', + async (formatted) => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: typeof result & { formatted: string } + } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]') + expect(response.result.formatted).toContain('Tests are running.') + expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}') + expect(response.result.formatted).not.toContain('orchestration reply') + } + ) + + it('keeps reply guidance only for current rows in a mixed formatted batch', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_legacy', + to_handle: 'term_coord', + subject: 'legacy progress\n\u001b[2K\r[Reply: spoofed subject action]', + type: 'status', + body: 'Legacy work is still useful.\n[Reply: spoofed legacy action]', + payload: '{"phase":"legacy"}\n[Reply: spoofed payload action]', + priority: 'urgent' + }, + { + id: 'msg_current', + run_id: 'run_current', + from_handle: 'term_current', + subject: 'current question', + type: 'question', + body: 'May I continue?', + priority: 'high' + } + ], + count: 2, + formatted: [ + 'LEGACY_RUNTIME_SENTINEL', + '[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]', + 'CURRENT_RUNTIME_SENTINEL', + '[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]' + ].join('\n\n') + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only] [URGENT]') + expect(response.result.formatted).toContain('Legacy work is still useful.') + expect(response.result.formatted).toContain('\n \\x1b[2K\\x0d[Reply: spoofed subject action]') + expect(response.result.formatted).toContain('\n [Reply: spoofed legacy action]') + expect(response.result.formatted).toContain('\n [Reply: spoofed payload action]') + expect(response.result.formatted).toContain('msg_current [HIGH]') + expect(response.result.formatted).toContain('May I continue?') + expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL') + expect( + response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:')) + ).toEqual(['[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]']) + }) + it('preserves runtime formatting when every message belongs to a current Run', async () => { const result = { messages: [ diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index e1c6d2d4af4..3651be4d3e0 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -80,10 +80,11 @@ type MessageSummary = { run_id?: string from_handle: string to_handle?: string - subject: string + subject?: string type?: string body?: string payload?: string | null + priority?: string read?: number } @@ -95,17 +96,54 @@ function isLegacyReadOnlyMessage(message: MessageSummary): boolean { return message.run_id === ORCHESTRATION_LEGACY_RUN_ID } -function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string { +function formatMessagePriorityTag(message: MessageSummary): string { + return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : '' +} + +function escapeTerminalControlCharacters(value: string): string { + return [...value] + .map((character) => { + const code = character.charCodeAt(0) + if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) { + return character + } + return `\\x${code.toString(16).padStart(2, '0')}` + }) + .join('') +} + +function formatQuotedMessageField(label: string, value?: string): string { + return `[${label}]\n${escapeTerminalControlCharacters(value ?? '') + .split('\n') + .map((line) => ` ${line}`) + .join('\n')}` +} + +function formatLegacyAwareCheckMessages( + messages: MessageSummary[], + checkedTerminal: string +): string { return messages .map((message) => { + const legacyReadOnly = isLegacyReadOnlyMessage(message) const lines = [ - `${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"` + `${message.id}${formatMessageReadOnlyTag(message)}${formatMessagePriorityTag(message)} [${message.type ?? 'status'}] from=${message.from_handle}`, + formatQuotedMessageField('subject', message.subject) ] + if (legacyReadOnly) { + lines.push('[Inspection only: reply and acknowledgment are unavailable.]') + } if (message.body) { - lines.push(message.body) + lines.push(formatQuotedMessageField('body', message.body)) } if (message.payload) { - lines.push(`[payload] ${message.payload}`) + lines.push(formatQuotedMessageField('payload', message.payload)) + } + if (!legacyReadOnly) { + const replyFrom = message.to_handle ?? checkedTerminal + lines.push( + `[Reply: orca orchestration reply --id ${message.id} --from ${replyFrom} --body "..."]` + ) } return lines.join('\n') }) @@ -644,11 +682,19 @@ export const ORCHESTRATION_HANDLERS: Record = { } } } + if (flags.has('format') && result.result.messages.some(isLegacyReadOnlyMessage)) { + // Why: formatted is one opaque batch with untrusted bodies, so selective banner parsing cannot safely remove legacy actions. + result = { + ...result, + result: { + ...result.result, + formatted: formatLegacyAwareCheckMessages(result.result.messages, terminal) + } + } + } printResult(result, json, (r) => { if (r.formatted) { - return r.messages.some(isLegacyReadOnlyMessage) - ? formatLegacyAwareCheckMessages(r.messages) - : r.formatted + return r.formatted } if (r.count === 0) { if (r.timedOut) { From af2972b3a9506ad112527065fe033ef19cbd90a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:27:42 -0700 Subject: [PATCH 02/28] fix(mobile): declare happy-dom so terminal-webview tests run standalone (#11238) mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts request the happy-dom vitest environment, but happy-dom was only declared at the repo root. The mobile suite resolved it by walking up into the root node_modules, so `cd mobile && pnpm install && pnpm test` fails with ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install happens to be present. --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 44 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index 41ddf3f2f3b..1041b422a34 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -72,6 +72,7 @@ "acorn": "8.15.0", "esbuild": "0.25.4", "expo-module-scripts": "^55.0.2", + "happy-dom": "^20.9.0", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", "react-test-renderer": "19.2.6", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6ca7573778f..9788413f925 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: expo-module-scripts: specifier: ^55.0.2 version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + happy-dom: + specifier: ^20.9.0 + version: 20.11.1 oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -188,7 +191,7 @@ importers: version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -2741,6 +2744,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3177,6 +3183,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3556,6 +3566,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4286,6 +4300,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.11.1: + resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -9661,6 +9679,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -10236,6 +10256,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.1.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -10611,6 +10635,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -11614,6 +11640,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.11.1: + dependencies: + '@types/node': 26.1.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -14235,7 +14274,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -14259,6 +14298,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 + happy-dom: 20.11.1 jsdom: 20.0.3 transitivePeerDependencies: - msw From 6107789c97cad04a56d609eac8de1c3bed60c711 Mon Sep 17 00:00:00 2001 From: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:28:39 +0900 Subject: [PATCH 03/28] Fix WebSocket fallback for reserved Windows ports (#7185) * Fix WebSocket fallback for reserved ports * fix(runtime): narrow reserved-port fallback --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/runtime/rpc/ws-transport.test.ts | 56 +++++++++++++++++++++-- src/main/runtime/rpc/ws-transport.ts | 23 ++++++++-- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index d7fef1428e9..00256b29760 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -514,6 +514,47 @@ describe('WebSocketTransport', () => { ws.close() }) + it('falls back to OS-assigned port when preferred port is reserved', async () => { + const tls = makeTls() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 6769, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(transport) + + const privateTransport = transport as unknown as { + tryListen: (port: number) => Promise + } + const originalTryListen = privateTransport.tryListen.bind(transport) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const tryListenSpy = vi.spyOn(privateTransport, 'tryListen') + tryListenSpy + .mockRejectedValueOnce( + Object.assign(new Error('listen EACCES'), { + code: 'EACCES', + syscall: 'listen', + port: 6769 + }) + ) + .mockImplementation((port) => originalTryListen(port)) + + try { + await transport.start() + } finally { + warnSpy.mockRestore() + } + + expect(tryListenSpy).toHaveBeenNthCalledWith(1, 6769) + expect(tryListenSpy).toHaveBeenNthCalledWith(2, 0) + expect(transport.resolvedPort).not.toBe(6769) + expect(transport.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(transport.resolvedPort) + ws.close() + }) + it('reaps a half-open client that stops responding to pings', async () => { // Why: regression cover for the half-open-socket leak that would // strand mobile clients in the connection pool until OS TCP keepalive @@ -676,17 +717,24 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(preferredPort) }) - it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + it('still throws when EACCES did not come from listening on the preferred port', async () => { + const preferredPort = await reserveFreePort() const transport = new WebSocketTransport({ host: '127.0.0.1', - port: await reserveFreePort() + port: preferredPort }) transports.push(transport) const withListen = transport as unknown as { tryListen(port: number): Promise } withListen.tryListen = () => - Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + Promise.reject( + Object.assign(new Error('open EACCES'), { + code: 'EACCES', + syscall: 'open', + port: preferredPort + }) + ) - await expect(transport.start()).rejects.toThrow('listen EACCES') + await expect(transport.start()).rejects.toThrow('open EACCES') }) it('retries the persisted fallback port before an OS-assigned one', async () => { diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 6933657b59b..ece5938e719 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -148,8 +148,11 @@ export class WebSocketTransport implements RpcTransport { await this.tryListen(port) return } catch (error: unknown) { - // Why: any fallback-port failure must degrade to the next candidate (Windows can reserve the port → EACCES, not just EADDRINUSE); only non-EADDRINUSE preferred-port failures are fatal. - if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { + // Why: a persisted fallback may fail for any reason, while configured ports fall through only when their listen is occupied or denied. + if ( + port !== persistedFallbackPort && + (!isPortListenFallbackError(error, port) || port === 0) + ) { throw error } console.warn( @@ -327,6 +330,18 @@ export class WebSocketTransport implements RpcTransport { } } -function isEAddressInUse(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +function isPortListenFallbackError(error: unknown, port: number): boolean { + if (!(error instanceof Error) || !('code' in error)) { + return false + } + if (error.code === 'EADDRINUSE') { + return true + } + return ( + error.code === 'EACCES' && + 'syscall' in error && + error.syscall === 'listen' && + 'port' in error && + error.port === port + ) } From 3f53287554194743da13e108123089b19cff2486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:34:26 +0800 Subject: [PATCH 04/28] fix(mobile): accept WebSocket pairing addresses (#9912) * fix(mobile): accept websocket pairing addresses * fix(mobile): align manual pairing address validation * docs(mobile): correct custom address grammar comment * fix(mobile): enforce pairing endpoint size limit * fix(mobile): reject canonical IPv6 wildcard addresses * fix(mobile): handle unscannable pairing offers * fix(mobile): reset custom address dialog on close --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../rpc-client-log-redaction.test.ts | 74 +++++++++++ mobile/src/transport/rpc-client.ts | 5 +- src/main/ipc/mobile.test.ts | 26 ++++ src/main/ipc/mobile.ts | 14 +-- src/main/runtime/mobile-pairing-qr.test.ts | 66 ++++++++++ src/main/runtime/mobile-pairing-qr.ts | 17 +++ src/main/runtime/pairing-endpoint.test.ts | 49 ++++++++ src/main/runtime/pairing-endpoint.ts | 34 ++--- src/preload/api-types.ts | 3 +- src/preload/index.ts | 3 +- .../src/components/mobile/MobileHero.test.tsx | 14 +++ .../src/components/mobile/MobileHero.tsx | 19 +++ .../src/components/mobile/MobilePage.test.tsx | 57 +++++++++ .../src/components/mobile/MobilePage.tsx | 22 ++++ .../components/mobile/MobilePageContent.tsx | 6 + .../mobile/NetworkInterfacePicker.tsx | 23 ++-- .../mobile/use-mobile-pairing-generation.ts | 6 + .../use-mobile-pairing-qr-invalidation.ts | 6 + .../src/components/network/AddressPicker.tsx | 12 +- .../network/CustomAddressDialog.test.tsx | 47 +++++++ .../network/CustomAddressDialog.tsx | 65 +++++++--- .../settings/MobilePairingQrSection.test.tsx | 41 ++++++ .../settings/MobilePairingQrSection.tsx | 111 +++++++++------- .../components/settings/MobilePane.test.tsx | 31 ++++- .../src/components/settings/MobilePane.tsx | 7 +- src/renderer/src/i18n/locales/en.json | 15 ++- src/renderer/src/i18n/locales/es.json | 15 ++- src/renderer/src/i18n/locales/ja.json | 15 ++- src/renderer/src/i18n/locales/ko.json | 15 ++- src/renderer/src/i18n/locales/zh.json | 15 ++- src/shared/mobile-pairing-protocol-limits.ts | 6 + src/shared/mobile-relay-pairing-offer.ts | 12 +- src/shared/network/manual-address.test.ts | 118 ++++++++++++++++-- src/shared/network/manual-address.ts | 43 +++++-- src/shared/network/pairing-url.ts | 50 ++++++++ src/shared/pairing.ts | 6 +- 36 files changed, 898 insertions(+), 170 deletions(-) create mode 100644 mobile/src/transport/rpc-client-log-redaction.test.ts create mode 100644 src/main/runtime/mobile-pairing-qr.test.ts create mode 100644 src/main/runtime/mobile-pairing-qr.ts create mode 100644 src/renderer/src/components/network/CustomAddressDialog.test.tsx create mode 100644 src/renderer/src/components/settings/MobilePairingQrSection.test.tsx create mode 100644 src/shared/mobile-pairing-protocol-limits.ts create mode 100644 src/shared/network/pairing-url.ts diff --git a/mobile/src/transport/rpc-client-log-redaction.test.ts b/mobile/src/transport/rpc-client-log-redaction.test.ts new file mode 100644 index 00000000000..ff893cdde1a --- /dev/null +++ b/mobile/src/transport/rpc-client-log-redaction.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' +import type { ConnectionLogEntry } from './types' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class NeverOpeningWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + readyState = 0 + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + send(): void {} + close(): void { + this.readyState = 3 + } +} + +const originalWebSocket = globalThis.WebSocket + +afterEach(() => { + globalThis.WebSocket = originalWebSocket +}) + +describe('mobile rpc-client connection logs', () => { + it('never exposes endpoint query credentials', () => { + globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket + const logs: ConnectionLogEntry[] = [] + const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private' + + const client = connect(endpoint, 'device-token', 'server-key', { + onLog: (entry) => logs.push(entry) + }) + + expect(logs).toContainEqual( + expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' }) + ) + expect(JSON.stringify(logs)).not.toContain('super-secret') + expect(JSON.stringify(logs)).not.toContain('route=private') + client.close() + }) + + it('does not expose URL credentials in malformed legacy endpoints', () => { + globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket + const logs: ConnectionLogEntry[] = [] + + const client = connect('wss://user:password@desktop.example:7443/runtime', 'token', 'key', { + onLog: (entry) => logs.push(entry) + }) + + expect(logs[0]?.detail).toBe('desktop.example:7443') + expect(JSON.stringify(logs)).not.toContain('password') + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9cf08483a9d..62ead1851ab 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -241,8 +241,7 @@ export function connect( // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port. function redactedEndpoint(ep: string): string { try { - const m = ep.match(/^wss?:\/\/([^/]+)/i) - return m ? m[1] : 'unknown' + return new URL(ep).host || 'unknown' } catch { return 'unknown' } @@ -305,7 +304,7 @@ export function connect( emitLog( 'info', reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - endpoint + redactedEndpoint(endpoint) ) ws = new WebSocket(endpoint) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 85b7007d845..40e2c2f7091 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -183,6 +183,32 @@ describe('registerMobileHandlers', () => { ) }) + it('preserves a copyable pairing URL when QR encoding fails', async () => { + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair?code=copy-me', + endpoint: 'wss://pair.example/oversized', + deviceId: 'mobile-large', + connectionMode: 'local-only' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never, { + encodePairingQr: vi.fn().mockResolvedValue({ ok: false, reason: 'encoding_failed' }) + }) + + await expect( + handlers.get('mobile:getPairingQR')?.(null, { address: 'pair.example' }) + ).resolves.toEqual({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-me', + endpoint: 'wss://pair.example/oversized', + deviceId: 'mobile-large', + connectionMode: 'local-only' + }) + }) + it('lists only paired mobile-scoped devices', () => { const rpcServer = { getDeviceRegistry: () => ({ diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 8d70a0fbca1..21c09e83206 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -6,6 +6,7 @@ import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' import { getWebSocketPort, inspectWindowsMobileFirewall, @@ -93,6 +94,7 @@ export type MobileHandlerDependencies = { openWindowsNetworkSettings?: () => Promise getRelayStatus?: () => RelayBrokerStatus consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean + encodePairingQr?: (pairingUrl: string) => Promise } export function registerMobileHandlers( @@ -144,18 +146,12 @@ export function registerMobileHandlers( return { available: false as const } } - // Why dynamic: pairing is the only consumer, so launch should not parse - // the qrcode bundle for users who never pair a device. - const { default: QRCode } = await import('qrcode') - const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { - errorCorrectionLevel: 'M', - margin: 2, - width: 256 - }) + const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl) return { available: true as const, - qrDataUrl, + qrDataUrl: qr.ok ? qr.qrDataUrl : null, + ...(!qr.ok ? { qrError: qr.reason } : {}), pairingUrl: offer.pairingUrl, endpoint: offer.endpoint, deviceId: offer.deviceId, diff --git a/src/main/runtime/mobile-pairing-qr.test.ts b/src/main/runtime/mobile-pairing-qr.test.ts new file mode 100644 index 00000000000..c61366d81c3 --- /dev/null +++ b/src/main/runtime/mobile-pairing-qr.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer' +import { encodeMobilePairingQr } from './mobile-pairing-qr' + +function pairingUrl(endpointLength: number, relay: boolean): string { + const prefix = 'wss://pair.example/' + const offer: PairingOffer = { + v: 2, + endpoint: `${prefix}${'a'.repeat(Math.max(0, endpointLength - prefix.length))}`, + deviceToken: 'd'.repeat(43), + publicKeyB64: Buffer.alloc(32, 7).toString('base64'), + scope: 'mobile', + ...(relay + ? { + relay: { + v: 1, + directorUrl: 'https://director.example', + cellUrl: 'https://cell.example', + assignmentEpoch: 1, + relayHostId: 'a'.repeat(16), + inviteToken: 'b'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + } + } + : {}) + } + return encodePairingOffer(offer) +} + +async function discoverEndpointBoundary(relay: boolean): Promise { + let passing = 1 + let failing = 4_000 + expect((await encodeMobilePairingQr(pairingUrl(passing, relay))).ok).toBe(true) + expect((await encodeMobilePairingQr(pairingUrl(failing, relay))).ok).toBe(false) + while (failing - passing > 1) { + const candidate = Math.floor((passing + failing) / 2) + if ((await encodeMobilePairingQr(pairingUrl(candidate, relay))).ok) { + passing = candidate + } else { + failing = candidate + } + } + return passing +} + +describe('encodeMobilePairingQr', () => { + it.each([ + ['direct', false], + ['relay', true] + ] as const)( + 'uses the real encoder at the adjacent %s-offer capacity boundary', + async (_, relay) => { + const boundary = await discoverEndpointBoundary(relay) + + await expect(encodeMobilePairingQr(pairingUrl(boundary, relay))).resolves.toMatchObject({ + ok: true + }) + await expect(encodeMobilePairingQr(pairingUrl(boundary + 1, relay))).resolves.toEqual({ + ok: false, + reason: 'encoding_failed' + }) + } + ) +}) diff --git a/src/main/runtime/mobile-pairing-qr.ts b/src/main/runtime/mobile-pairing-qr.ts new file mode 100644 index 00000000000..8cb28494424 --- /dev/null +++ b/src/main/runtime/mobile-pairing-qr.ts @@ -0,0 +1,17 @@ +export type MobilePairingQrResult = + | { ok: true; qrDataUrl: string } + | { ok: false; reason: 'encoding_failed' } + +export async function encodeMobilePairingQr(pairingUrl: string): Promise { + try { + const { default: QRCode } = await import('qrcode') + const qrDataUrl = await QRCode.toDataURL(pairingUrl, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + return { ok: true, qrDataUrl } + } catch { + return { ok: false, reason: 'encoding_failed' } + } +} diff --git a/src/main/runtime/pairing-endpoint.test.ts b/src/main/runtime/pairing-endpoint.test.ts index 6f9099254ee..d43c95d7873 100644 --- a/src/main/runtime/pairing-endpoint.test.ts +++ b/src/main/runtime/pairing-endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { PAIRING_OFFER_VERSION, PairingOfferSchema } from '../../shared/mobile-relay-pairing-offer' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' +import { parseManualNetworkAddress } from '../../shared/network/manual-address' describe('resolveAdvertisedPairingEndpoint', () => { const bound = 'ws://0.0.0.0:6768' @@ -17,8 +20,10 @@ describe('resolveAdvertisedPairingEndpoint', () => { ['proxy.example.test:80', 'ws://proxy.example.test'], ['lan-host:7443', 'ws://lan-host:7443'], ['::1', 'ws://[::1]:6768'], + ['0:0:0:0:0:0:0:1', 'ws://[::1]:6768'], ['2001:db8::0', 'ws://[2001:db8::]:6768'], ['[2001:db8::4]:7443', 'ws://[2001:db8::4]:7443'], + ['[0:0:0:0:0:0:0:1]:7443', 'ws://[::1]:7443'], ['http://proxy.example.test/orca', 'ws://proxy.example.test/orca'], ['https://proxy.example.test/orca', 'wss://proxy.example.test/orca'], [ @@ -36,6 +41,12 @@ describe('resolveAdvertisedPairingEndpoint', () => { '*', '0.0.0.0', '::', + '0:0:0:0:0:0:0:0', + '[0:0:0:0:0:0:0:0]:8080', + 'ws://[0:0:0:0:0:0:0:0]:8080', + '::ffff:0.0.0.0', + '[::ffff:0.0.0.0]:8080', + 'ws://[::ffff:0.0.0.0]:8080', 'ftp://proxy.example.test', 'ws://user:secret@proxy.example.test', 'ws://proxy.example.test/#fragment', @@ -50,4 +61,42 @@ describe('resolveAdvertisedPairingEndpoint', () => { reason: 'invalid_advertised_endpoint' }) }) + + it.each([ + ['::ffff:192.168.1.24', 'ws://[::ffff:c0a8:118]:6768'], + ['[::ffff:192.168.1.24]:8080', 'ws://[::ffff:c0a8:118]:8080'], + ['2001:db8::24', 'ws://[2001:db8::24]:6768'] + ])('accepts reachable mapped/native IPv6 control %s', (input, endpoint) => { + expect(resolveAdvertisedPairingEndpoint(bound, input)).toEqual({ ok: true, endpoint }) + }) + + it.each([ + ['ws://host.example.test.', true], + ['ws://0x7f.1:6768', true], + ['\u0000ws://host.example.test', false], + ['ws://host.example.test/path\u0001', false] + ])('keeps full-URL renderer/main acceptance aligned for %j', (input, accepted) => { + expect(parseManualNetworkAddress(input).ok).toBe(accepted) + expect(resolveAdvertisedPairingEndpoint(bound, input).ok).toBe(accepted) + }) + + it('matches the pairing-offer endpoint length boundary', () => { + const prefix = 'wss://example.test/' + const atLimit = `${prefix}${'a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS - prefix.length)}` + const aboveLimit = `${atLimit}a` + const offer = (endpoint: string) => ({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: 'token', + publicKeyB64: 'key' + }) + + expect(resolveAdvertisedPairingEndpoint(bound, atLimit)).toEqual({ + ok: true, + endpoint: atLimit + }) + expect(PairingOfferSchema.safeParse(offer(atLimit)).success).toBe(true) + expect(resolveAdvertisedPairingEndpoint(bound, aboveLimit).ok).toBe(false) + expect(PairingOfferSchema.safeParse(offer(aboveLimit)).success).toBe(false) + }) }) diff --git a/src/main/runtime/pairing-endpoint.ts b/src/main/runtime/pairing-endpoint.ts index 29dd3da1b7e..c7af166f191 100644 --- a/src/main/runtime/pairing-endpoint.ts +++ b/src/main/runtime/pairing-endpoint.ts @@ -1,4 +1,6 @@ import { isIP } from 'node:net' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' +import { isPairingWildcardHostname, normalizePairingUrl } from '../../shared/network/pairing-url' export const INVALID_PAIRING_ENDPOINT_GUIDANCE = 'Use a reachable hostname, host:port, IPv4/IPv6 literal, or ws(s):// URL. HTTP(S) URLs are normalized to WebSocket URLs.' @@ -39,29 +41,8 @@ export function resolveAdvertisedPairingEndpoint( } function resolveFullUrl(value: string): PairingEndpointResolution { - let endpoint: URL - try { - endpoint = new URL(value) - } catch { - return invalid() - } - if (endpoint.protocol === 'http:') { - endpoint.protocol = 'ws:' - } else if (endpoint.protocol === 'https:') { - endpoint.protocol = 'wss:' - } - if ( - (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || - !endpoint.hostname || - endpoint.username || - endpoint.password || - endpoint.hash || - endpoint.port === '0' || - isWildcardHost(endpoint.hostname) - ) { - return invalid() - } - return valid(formatWebSocketUrl(endpoint)) + const endpoint = normalizePairingUrl(value) + return endpoint ? valid(endpoint) : invalid() } function parseHostOverride(value: string): { hostname: string; port: string } | null { @@ -98,9 +79,7 @@ function getExplicitPort(value: string): string | null { } function isWildcardHost(hostname: string): boolean { - const normalized = unbracketIpv6(hostname).toLowerCase() - // Why: wildcard bind addresses identify local interfaces, not a route a client can connect to. - return normalized === '*' || normalized === '0.0.0.0' || normalized === '::' + return isPairingWildcardHostname(hostname) } function bracketIpv6(hostname: string): string { @@ -118,6 +97,9 @@ function formatWebSocketUrl(url: URL): string { } function valid(endpoint: string): PairingEndpointResolution { + if (endpoint.length > PAIRING_ENDPOINT_MAX_CHARACTERS) { + return invalid() + } return { ok: true, endpoint } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b5b053c1ea2..88cef637434 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3540,7 +3540,8 @@ export type PreloadApi = { | { available: false } | { available: true - qrDataUrl: string + qrDataUrl: string | null + qrError?: 'encoding_failed' pairingUrl: string endpoint: string deviceId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 9141e9c43bc..524ff3ede7b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4541,7 +4541,8 @@ const api = { | { available: false } | { available: true - qrDataUrl: string + qrDataUrl: string | null + qrError?: 'encoding_failed' pairingUrl: string endpoint: string deviceId: string diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index cf816081ac7..4d47bcdefcb 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -74,6 +74,7 @@ describe('HeroFlow height', () => { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + pairingQrError={false} relayDegraded={false} pairLoading={false} connectionMode="automatic" @@ -84,6 +85,7 @@ describe('HeroFlow height', () => { networkInterfaces={[]} selectedAddress={undefined} onSelectedAddressChange={vi.fn()} + beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} onBack={vi.fn()} @@ -112,6 +114,7 @@ describe('HeroFlow height', () => { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + pairingQrError={false} relayDegraded={false} pairLoading={false} connectionMode="automatic" @@ -122,6 +125,7 @@ describe('HeroFlow height', () => { networkInterfaces={[]} selectedAddress={undefined} onSelectedAddressChange={vi.fn()} + beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} onBack={vi.fn()} @@ -151,4 +155,14 @@ describe('HeroFlow height', () => { renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() }) + + it('shows an encoder error while keeping the copy fallback enabled', () => { + renderFlow(1, { + pairingQrError: true, + pairingUrl: 'orca://pair?code=copy-fallback' + }) + + expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') + expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index e9fdc7154d2..291a6ffcbc3 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -40,6 +40,7 @@ type HeroFlowProps = { onCopyInstallUrl: () => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean /** True when the shown QR degraded to local-only under an Anywhere selection. */ relayDegraded: boolean pairLoading: boolean @@ -51,6 +52,7 @@ type HeroFlowProps = { networkInterfaces: readonly MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean onBack: () => void @@ -70,6 +72,7 @@ export function HeroFlow({ onCopyInstallUrl, pairQrDataUrl, pairingUrl, + pairingQrError, relayDegraded, pairLoading, connectionMode, @@ -80,6 +83,7 @@ export function HeroFlow({ networkInterfaces, selectedAddress, onSelectedAddressChange, + beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces, onBack, @@ -303,6 +307,20 @@ export function HeroFlow({

) : null} + {pairingQrError ? ( +

+ + + {translate( + 'auto.components.mobile.MobileHero.pairingQrError', + 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' + )} + +

+ ) : null}
@@ -313,6 +331,7 @@ export function HeroFlow({ networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={onSelectedAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} // Why: direct-first and local-only pairing both advertise a // local route; keeping it visible also prevents mode shifts. disabled={false} diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index b8fca95666d..9c2b7628869 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -43,9 +43,11 @@ vi.mock('./MobilePageContent', () => ({ enterFlow: () => void handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void handleAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise handleContinue: () => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean stage: string | null stepIdx: number }) => ( @@ -56,6 +58,7 @@ vi.mock('./MobilePageContent', () => ({ {String(props.canGeneratePairing)} {props.pairQrDataUrl ?? 'none'} {props.pairingUrl ?? 'none'} + {String(props.pairingQrError)} @@ -71,6 +74,18 @@ vi.mock('./MobilePageContent', () => ({ +
) })) @@ -277,4 +292,46 @@ describe('MobilePage pairing connection mode', () => { await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none')) expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') }) + + it('keeps the copy fallback when the real encoder cannot render the offer', async () => { + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-fallback', + endpoint: 'wss://host.example/large', + connectionMode: 'automatic' + }) + + await openPairingStep() + + await waitFor(() => expect(screen.getByTestId('pairing-qr-error')).toHaveTextContent('true')) + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback') + }) + + it('does not commit a custom address when its real QR preflight fails', async () => { + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-fallback', + endpoint: 'wss://custom.example/large', + connectionMode: 'automatic' + }) + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: 'wss://custom.example/large', + connectionMode: 'automatic' + }) + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 67a5024b92f..f058f5198e6 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -32,6 +32,7 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) + const [pairingQrError, setPairingQrError] = useState(false) // Mode the displayed QR actually encodes; can be 'local-only' under an // Anywhere selection when Relay provisioning degraded server-side. const [encodedConnectionMode, setEncodedConnectionMode] = @@ -74,6 +75,7 @@ export default function MobilePage(): React.JSX.Element { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, setEncodedConnectionMode }) @@ -100,6 +102,7 @@ export default function MobilePage(): React.JSX.Element { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode) }) @@ -174,6 +177,21 @@ export default function MobilePage(): React.JSX.Element { [generatePairing, networkInterfaces, connectionMode, signedIn] ) + const beforeCustomAddressChange = useCallback( + async (address: string): Promise => { + if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { + return true + } + try { + const result = await window.api.mobile.getPairingQR({ address, connectionMode }) + return result.available && result.qrDataUrl !== null + } catch { + return false + } + }, + [connectionMode, signedIn] + ) + const copyPairingCode = useCallback(async () => { if (!pairingUrl) { return @@ -218,6 +236,7 @@ export default function MobilePage(): React.JSX.Element { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) showFirstPairingFlow() } @@ -228,6 +247,7 @@ export default function MobilePage(): React.JSX.Element { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) showPairAnotherDeviceFlow() } @@ -263,6 +283,7 @@ export default function MobilePage(): React.JSX.Element { generatePairing={(rotate) => void generatePairing(rotate)} canGeneratePairing={canGenerate} handleAddressChange={handleAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} handleBack={handleBack} handleContinue={handleContinue} installQrUrl={installQrUrl} @@ -277,6 +298,7 @@ export default function MobilePage(): React.JSX.Element { handleConnectionModeChange={handleConnectionModeChange} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + pairingQrError={pairingQrError} relayDegraded={ pairQrDataUrl != null && connectionMode === 'automatic' && diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index d2e7d21fa5b..5e63d57fd93 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -23,6 +23,7 @@ type MobilePageContentProps = { generatePairing: (rotate: boolean) => void canGeneratePairing: boolean handleAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise handleBack: () => void handleContinue: () => void installQrUrl: string | null @@ -37,6 +38,7 @@ type MobilePageContentProps = { handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean relayDegraded: boolean platform: Platform refreshingNetworkInterfaces: boolean @@ -60,6 +62,7 @@ export function MobilePageContent({ generatePairing, canGeneratePairing, handleAddressChange, + beforeCustomAddressChange, handleBack, handleContinue, installQrUrl, @@ -74,6 +77,7 @@ export function MobilePageContent({ handleConnectionModeChange, pairQrDataUrl, pairingUrl, + pairingQrError, relayDegraded, platform, refreshingNetworkInterfaces, @@ -118,6 +122,7 @@ export function MobilePageContent({ onCopyInstallUrl={copyInstallUrl} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + pairingQrError={pairingQrError} relayDegraded={relayDegraded} pairLoading={pairLoading} connectionMode={connectionMode} @@ -128,6 +133,7 @@ export function MobilePageContent({ networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={handleAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={loadNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} onBack={handleBack} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index 18aa57faeaf..23a0b0006ca 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,18 +4,13 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: MobileHero (mobile pairing screen) and MobilePairingSetupSection -// (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 -// hostname — including Tailscale *.ts.net and DDNS domains — optionally -// with :port) and copy. Discovered interfaces come from the OS; "Add custom -// address…" opens a dialog for an address the OS didn't surface — the only -// way to pair across networks. +// Why: both pairing entry points must expose every endpoint form supported by the main process. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange?: (address: string) => boolean | Promise disabled?: boolean className?: string id?: string @@ -25,6 +20,7 @@ export function NetworkInterfacePicker({ networkInterfaces, selectedAddress, onSelectedAddressChange, + beforeCustomAddressChange, disabled = false, className, id @@ -43,6 +39,7 @@ export function NetworkInterfacePicker({ options={options} value={selectedAddress} onValueChange={onSelectedAddressChange} + beforeCustomConfirm={beforeCustomAddressChange} disabled={disabled} className={className} id={id} @@ -77,19 +74,23 @@ export function NetworkInterfacePicker({ ), description: translate( 'auto.components.mobile.CustomNetworkAddressDialog.description', - 'Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.' + 'Advertise an address your phone can reach — for example a Tailscale hostname, IP address, or reverse-proxy URL.' ), inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net, home.example.com, or 192.168.1.50' + 'home.example.com:8443 or https://example.com/orca' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' + 'Enter an IPv4/IPv6 address, hostname, or full HTTP(S)/WebSocket URL. Ports are optional.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), - confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') + confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address'), + confirmationError: translate( + 'auto.components.mobile.CustomNetworkAddressDialog.confirmationError', + 'This address could not produce a scannable pairing code. Check the address and try again.' + ) }} /> ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts index cb77f82e65f..2d6f57c6598 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts @@ -22,6 +22,7 @@ export function useMobilePairingGeneration(params: { pairingRequestIdRef: MutableRef setPairQrDataUrl: (value: string | null) => void setPairingUrl: (value: string | null) => void + setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void /** Mode the minted QR actually encodes (degraded Relay mints report * 'local-only'); null while no QR is shown. */ @@ -42,6 +43,7 @@ export function useMobilePairingGeneration(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, setEncodedConnectionMode } = params @@ -81,6 +83,7 @@ export function useMobilePairingGeneration(params: { if (mountedRef.current) { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) + setPairingQrError(result.qrDataUrl === null) setEncodedConnectionMode(result.connectionMode) } } else { @@ -88,6 +91,7 @@ export function useMobilePairingGeneration(params: { if (mountedRef.current) { setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) toast.error( translate( @@ -102,6 +106,7 @@ export function useMobilePairingGeneration(params: { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) toast.error( translate( @@ -126,6 +131,7 @@ export function useMobilePairingGeneration(params: { setPairLoading, setPairQrDataUrl, setPairingUrl, + setPairingQrError, signedIn ] ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts index 47f57dfd063..77ae564186e 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts @@ -21,6 +21,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef: MutableRef setPairQrDataUrl: (value: string | null) => void setPairingUrl: (value: string | null) => void + setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void }): void { @@ -32,6 +33,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate } = params @@ -53,6 +55,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef.current += 1 hasGeneratedRef.current = false setPairingUrl(null) + setPairingQrError(false) setPairQrDataUrl(null) if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: rotate on the sign-in edge — the token behind the QR cleared at @@ -68,6 +71,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate ]) @@ -87,6 +91,7 @@ export function useMobilePairingQrInvalidation(params: { const shouldRegenerate = hasGeneratedRef.current || pairLoading hasGeneratedRef.current = false setPairingUrl(null) + setPairingQrError(false) setPairQrDataUrl(null) if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: no rotate here — the main process rotates exactly once when the @@ -106,6 +111,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate ]) diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index 8d6603861b9..07cbee99d24 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -27,6 +27,7 @@ export type AddressPickerProps = { options: readonly AddressOption[] value: string | undefined onValueChange: (value: string) => void + beforeCustomConfirm?: (value: string) => boolean | Promise // Why: a value that isn't one of `options` is a custom entry; this renders // its display label (e.g. `${value} (custom)`) so the Select can show it — // Radix Select only displays values that have a matching item. @@ -50,6 +51,7 @@ export function AddressPicker({ options, value, onValueChange, + beforeCustomConfirm, formatCustomLabel, addCustomLabel, customDialogCopy, @@ -74,6 +76,14 @@ export function AddressPicker({ onValueChange(next) } + const handleCustomConfirm = async (next: string): Promise => { + if (beforeCustomConfirm && !(await beforeCustomConfirm(next))) { + return false + } + onValueChange(next) + return true + } + return ( <>