mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(mobile): redact hosted privacy surfaces
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
const runtimeCodeGenerationPattern = /\beval\s*\(|\bnew\s+Function\s*\(|sourceMappingURL/
|
||||
const buildEnvironmentPathPattern =
|
||||
/(?:\/Users\/[^/"'\s]+\/|\/home\/[^/"'\s]+\/|[A-Za-z]:\\\\Users\\\\[^\\/"'\s]+\\\\)/
|
||||
|
||||
const pagePersistencePatterns = [
|
||||
/\b(?:window\.)?(?:localStorage|sessionStorage)\s*\.\s*(?:getItem|setItem|removeItem|clear|key)\b/,
|
||||
@@ -13,6 +15,9 @@ export function mobileWebRnwExecutablePolicyFailure(source) {
|
||||
if (runtimeCodeGenerationPattern.test(source)) {
|
||||
return 'runtime code generation'
|
||||
}
|
||||
if (buildEnvironmentPathPattern.test(source)) {
|
||||
return 'build environment path disclosure'
|
||||
}
|
||||
if (pagePersistencePatterns.some((pattern) => pattern.test(source))) {
|
||||
return 'page-owned persistence'
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ describe('mobile web RNW executable policy', () => {
|
||||
expect(() => assertMobileWebRnwExecutablePolicy(source)).toThrow('page-owned persistence')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'const sourcePath="/Users/developer/orca/mobile/app.tsx"',
|
||||
'const sourcePath="/home/runner/work/orca/mobile/app.tsx"',
|
||||
String.raw`const sourcePath="C:\\Users\\builder\\orca\\mobile\\app.tsx"`
|
||||
])('rejects build environment paths: %s', (source) => {
|
||||
expect(mobileWebRnwExecutablePolicyFailure(source)).toBe('build environment path disclosure')
|
||||
})
|
||||
|
||||
it('allows inert syntax-highlighter keyword strings', () => {
|
||||
expect(
|
||||
mobileWebRnwExecutablePolicyFailure('["document","localStorage","sessionStorage","module"]')
|
||||
|
||||
@@ -549,7 +549,7 @@ recovery, or physical-device gates.
|
||||
RNW artifact. The retired Vite entry, package plugin/verifier, duplicate
|
||||
workspace/session/files/source-control UI, and UI-only tests are removed.
|
||||
Production bridge clients and transport tests remain. The rebuilt package
|
||||
now verifies as build `bcd9c95e…`.
|
||||
now verifies as build `e0ad7c7d…`.
|
||||
|
||||
## 3. Production Package Delivery RPC
|
||||
|
||||
@@ -1475,8 +1475,15 @@ copy.
|
||||
packaging and independent verification rejects executable access to Web
|
||||
Storage, IndexedDB, CacheStorage, cookies, WebSQL, and origin-private
|
||||
filesystem/storage persistence. It permits inert storage keywords used by
|
||||
the production syntax highlighter. The remaining URL, DOM/message, cache
|
||||
metadata, log, diagnostic, analytics, crash-report, and fixture audit is
|
||||
the production syntax highlighter and rejects macOS, Linux, and Windows
|
||||
build-machine user paths. Hosted URL construction owns a fixed page-host
|
||||
label and cannot accept a paired-host identifier. Browser state removes URL
|
||||
userinfo, signed/OAuth query or fragment credentials, and host-local file
|
||||
paths before entering the page; page-originated navigation rejects those
|
||||
values and unsupported schemes. Native transport and shared-route logs now
|
||||
drop endpoint, WebSocket/auth event values, repository identity, and raw
|
||||
errors in favor of protocol/state/category fields. The remaining DOM/message,
|
||||
cache metadata, diagnostic, analytics, crash-report, and fixture audit is
|
||||
still open.
|
||||
- [~] Verify the WebView cannot make network requests. Static iOS/Android CSP
|
||||
and native-origin controls are covered. Android also sets
|
||||
@@ -1927,9 +1934,10 @@ passes 576 files / 3,440 tests with 2 expected skips. A full root run passes
|
||||
30-second dynamic-import timeout; its isolated 2-test rerun passes in 4.69
|
||||
seconds. Mobile/root/RNW typechecks, mobile/shared lint, all 55 reliability
|
||||
gates, max-lines, localization, formatting, diff hygiene, and production
|
||||
package verification pass. The rebuilt package is
|
||||
`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`:
|
||||
50 assets, 9,299,540 raw bytes, and 2,691,263 gzip bytes.
|
||||
package verification pass. The latest privacy-hardening package is
|
||||
`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`:
|
||||
50 assets, 9,301,460 raw bytes, and 2,692,026 gzip bytes. The full mobile suite
|
||||
passes 580 files / 3,449 tests with 2 expected skips.
|
||||
|
||||
| Date | Workstream | Evidence | Result |
|
||||
| ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -2306,6 +2314,7 @@ package verification pass. The rebuilt package is
|
||||
| 2026-07-27 | Android corrupt cache | Forged and activated corrupt `ffff…`, stopped Desktop, cold-opened the exact APK, and inspected cache plus the private document with the durable harness | Passed; verified `48531616…` restored, forged generation removed, and bridge-ready workspace-list UI retained |
|
||||
| 2026-07-27 | Hosted storage boundary | Inert hosted AsyncStorage adapter, verifier rejection of executable `localStorage`, and terminal preference/accessory/custom-shortcut typed bridge operations | Passed; page-to-native shortcut read/write round trip and unchanged session adapter included in 7 focused files / 34 tests |
|
||||
| 2026-07-28 | Page persistence | Packaging and verification share rejection of runtime code generation plus Web Storage, IndexedDB, CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access | Focused policy/package/page-source tests pass; mobile and RNW typechecks, focused lint, max-lines, formatting, diff hygiene, and exact build `bcd9c95e…` verification pass |
|
||||
| 2026-07-28 | Privacy boundaries | Fixed page-host label; credential/file-path URL sanitization and navigation rejection; build-path rejection; value-free transport, pairing, Tasks, clipboard, dictation, and terminal logs | 23 focused root tests, 25 focused mobile tests, full 580-file mobile suite / 3,449 tests with 2 skips, typechecks, lint, max-lines, formatting, diff hygiene, and exact build `e0ad7c7d…` pass |
|
||||
| 2026-07-27 | Android private origin | Exact Debug APK at reserved HTTPS origin, main-frame fragment validation, hosted History API fragment retention, deliberate-red network/navigation corpus, and real routed terminal snapshot | Passed; `/h/.../session/...#<session>` retained, zero sentinel observations, bridge loaded one real tab, and fresh logcat had no prior exceptions |
|
||||
| 2026-07-27 | RNW package | Exact-source build and independent verifier after hosted History API session binding | Passed; build `8b5c9b64b4caa778603ff4e3845a7f3df9c6ed0f027560cd5447ed259ebbb0e8`, 49 assets, 9,178,634 raw bytes, 2,662,870 gzip bytes |
|
||||
| 2026-07-27 | Validation | Focused bridge/origin/history tests, mobile and mobile-web typechecks/lints, max-lines ratchet, Android JVM/process tests, exact Debug assembly, and diff hygiene | Passed; 34 focused tests, 17 Android JVM tests, 577 Gradle tasks, and no new max-lines bypass |
|
||||
@@ -2826,4 +2835,5 @@ package verification pass. The rebuilt package is
|
||||
| 2026-07-28 | Finding | The full root run passes 3,829 files / 40,205 tests with 70 expected skips but the unrelated `ProjectViewWrapper` dynamic-import boundary again reaches its 30-second timeout under full-suite load. Its isolated rerun passes 2/2 in 4.69 seconds. |
|
||||
| 2026-07-28 | Complete | Mirrored Swift and Kotlin package-store suites now pass 120 concurrent cache flows per platform. The added same-host matrix covers 16 competing distinct-generation commits, 16 live-session activation/cleanup flows with post-activation reads, and 16 interleaved commit/abort mutations; final activation retains at most active plus previous, and host removal leaves no cache subtree. |
|
||||
| 2026-07-28 | Complete | Packaging and independent verification now share one executable policy that rejects runtime code generation and page-owned Web Storage, IndexedDB, CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access while permitting inert syntax-highlighter keywords. Focused tests, typechecks, lint, max-lines, formatting, diff hygiene, and exact `bcd9c95e…` package verification pass. |
|
||||
| 2026-07-28 | Next | Continue the privacy audit across URLs, DOM/messages, cache metadata, logs, diagnostics, analytics, crash reports, and fixtures; then run exact release-app corpus injection, broader live two-host/topology races, and independent security review. |
|
||||
| 2026-07-28 | Complete | Hosted page URL construction no longer accepts a paired-host identifier. Browser results, events, and snapshots remove credentials and local file paths; hosted navigation rejects credential-bearing URLs. Packaging rejects build-machine paths. Mobile logs retain only reviewed protocol, state, count, and category fields. The full mobile suite passes 580 files / 3,449 tests with 2 skips, and build `e0ad7c7d…` verifies at 50 assets / 9,301,460 raw / 2,692,026 gzip bytes. |
|
||||
| 2026-07-28 | Next | Continue the privacy audit across DOM/messages, cache metadata, diagnostics, analytics, crash reports, and fixtures; then run exact release-app corpus injection, broader live two-host/topology races, and independent security review. |
|
||||
|
||||
@@ -339,8 +339,16 @@ operations. Packaging and independent verification share one executable policy
|
||||
that rejects runtime code generation and access to Web Storage, IndexedDB,
|
||||
CacheStorage, cookies, WebSQL, or origin-private filesystem/storage persistence.
|
||||
Bare storage keywords remain allowed because the production syntax highlighter
|
||||
contains inert keyword tables. Focused policy tests, page-to-native round trips,
|
||||
and the exact 9,299,540-byte production package pass.
|
||||
contains inert keyword tables. The same policy rejects macOS, Linux, and Windows
|
||||
build-machine user paths. Hosted URL construction owns its fixed page-host label
|
||||
and cannot accept a paired-host identifier. Browser results, events, and
|
||||
snapshots remove URL userinfo, signed/OAuth query or fragment credentials, and
|
||||
host-local file paths before entering the page; hosted navigation rejects those
|
||||
values and unsupported schemes. Native transport, pairing, Tasks, clipboard,
|
||||
dictation, and terminal error logs retain only reviewed protocol, state, count,
|
||||
and error-category fields rather than endpoint, event, repository, or error
|
||||
values. Focused policy tests, page-to-native round trips, and the exact
|
||||
9,301,460-byte production package pass.
|
||||
|
||||
This proves the core Android Debug emulator and deliberate isolation slices.
|
||||
The separate locally signed Release gate now passes on a production `user`
|
||||
@@ -2184,9 +2192,9 @@ The authoritative route now has a separately reviewed ceiling of 10 MiB total,
|
||||
from 8 MiB / 2 MiB / 7.5 MiB only after the legacy Mermaid CDN executable was
|
||||
replaced by the locally bundled, network-denied engine needed by both native
|
||||
and RNW without forking the existing UI. Boundary tests reject every
|
||||
measurement above its ceiling. The current 50-asset package is 9,299,540 bytes
|
||||
/ 2,691,263 bytes gzip and build
|
||||
`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`.
|
||||
measurement above its ceiling. The current 50-asset package is 9,301,460 bytes
|
||||
/ 2,692,026 bytes gzip and build
|
||||
`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`.
|
||||
`build:mobile-web` and release resource mapping therefore select the RNW
|
||||
package; the original 2 MiB Vite-fixture ceiling is retired with that fixture.
|
||||
Workspace snapshots now page through
|
||||
|
||||
@@ -113,8 +113,8 @@ changed-file and full-mobile formatting, localization, the max-lines ratchet,
|
||||
and diff hygiene pass. React Doctor reports zero blocking errors across the
|
||||
migration without suppressions. The independently verified React Native Web
|
||||
package is
|
||||
`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`:
|
||||
50 assets, 9,299,540 raw bytes, and 2,691,263 gzip bytes.
|
||||
`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`:
|
||||
50 assets, 9,301,460 raw bytes, and 2,692,026 gzip bytes.
|
||||
|
||||
The immediately preceding `7c7c673d…` package passed the unpacked macOS arm64 →
|
||||
Docker SSH → actual iOS WKWebView journey from a clean app reinstall in 1.9
|
||||
@@ -314,8 +314,8 @@ collection ceiling. Session subscription events are also bound to the
|
||||
requested workspace. Existing Source Control, provider-review, file, and
|
||||
Markdown correlation remains in force.
|
||||
|
||||
The production package now verifies as `bcd9c95e…`: 50 assets, 9,299,540 raw
|
||||
bytes, and 2,691,263 gzip bytes. The full mobile suite passes 576 files / 3,440
|
||||
The production package now verifies as `e0ad7c7d…`: 50 assets, 9,301,460 raw
|
||||
bytes, and 2,692,026 gzip bytes. The full mobile suite passes 580 files / 3,449
|
||||
tests with 2 expected skips. Mobile, mobile-web, and root typechecks; mobile and
|
||||
mobile-web lint; all 55 reliability gates; localization; max-lines; and package
|
||||
verification pass. The full root run passes 3,829 files / 40,205 tests with 70
|
||||
@@ -330,10 +330,15 @@ commit/abort mutations, bounded final retention, and host removal.
|
||||
Packaging and independent package verification now share one executable policy
|
||||
that rejects runtime code generation and page-owned Web Storage, IndexedDB,
|
||||
CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access. It
|
||||
permits inert storage keywords required by the production syntax highlighter.
|
||||
Focused tests and exact build `bcd9c95e…` verification pass. The remaining
|
||||
security work below is exact release-app corpus testing, broader live
|
||||
cross-scope races, the rest of the privacy audit, and independent review.
|
||||
permits inert storage keywords required by the production syntax highlighter
|
||||
and rejects build-machine user paths. Hosted URL construction cannot accept a
|
||||
paired-host identity. Browser state removes credentials and host-local file
|
||||
paths before entering the page, and hosted navigation rejects credential-bearing
|
||||
URLs. Native transport and shared-route logging removes endpoint, WebSocket/auth
|
||||
event, repository, and raw error values. Focused tests and exact build
|
||||
`e0ad7c7d…` verification pass. The remaining security work below is exact
|
||||
release-app corpus testing, broader live cross-scope races, the rest of the
|
||||
privacy audit, and independent review.
|
||||
|
||||
## 1. Production Cutover and Cleanup
|
||||
|
||||
@@ -410,8 +415,12 @@ cross-scope races, the rest of the privacy audit, and independent review.
|
||||
- [ ] Verify no credential or privileged host identity reaches URLs, DOM state,
|
||||
page storage, cache assets, logs, diagnostics, analytics, or fixtures.
|
||||
Executable access to browser-owned persistence now fails during both
|
||||
packaging and verification; URLs, DOM/messages, cache metadata, logs,
|
||||
diagnostics, analytics, crash reports, and fixtures remain under audit.
|
||||
packaging and verification. Hosted routes use a fixed page-host label, the
|
||||
package rejects build-machine paths, browser state removes credential and
|
||||
host-local file URLs, hosted navigation rejects credential-bearing URLs,
|
||||
and reviewed mobile logs retain only protocol/state/category fields.
|
||||
DOM/messages, cache metadata, remaining diagnostics, analytics, crash
|
||||
reports, and fixtures remain under audit.
|
||||
- [ ] Verify all resource limits apply before allocation and during assembly.
|
||||
Persisted native manifests, activation metadata, and assets have
|
||||
pre-allocation read ceilings; every bridge operation has generated
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import { loadSessionNativeHostProfile } from '../../../../src/session/session-native-host-profile'
|
||||
import { mobileLogErrorKind } from '../../../../src/diagnostics/mobile-log-error-kind'
|
||||
import { persistSessionLastVisitedWorktree } from '../../../../src/session/session-last-visited-worktree'
|
||||
import { startRuntimeCapabilityRead } from '../../../../src/transport/runtime-capability-probe'
|
||||
import {
|
||||
@@ -3529,11 +3530,9 @@ export function SessionScreen({
|
||||
terminalRefs.current.get(handle)?.cancelSelect()
|
||||
} catch (e) {
|
||||
triggerError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] setString failed', {
|
||||
name: err.name,
|
||||
message: err.message
|
||||
kind: mobileLogErrorKind(e)
|
||||
})
|
||||
showToast("Couldn't copy", 1500)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import { classifyConnection } from '../../../src/transport/connection-health'
|
||||
import type { ConnectionState } from '../../../src/transport/types'
|
||||
import { mobileLogErrorKind } from '../../../src/diagnostics/mobile-log-error-kind'
|
||||
import { StatusDot } from '../../../src/components/StatusDot'
|
||||
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
@@ -3271,11 +3272,10 @@ export default function MobileTasksScreen({
|
||||
} catch (err) {
|
||||
const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err)
|
||||
const logWorkItemFetchFailure = isExpectedSshSkip ? console.log : console.warn
|
||||
logWorkItemFetchFailure(
|
||||
'[mobile tasks] failed to fetch github work items',
|
||||
repo.id,
|
||||
isExpectedSshSkip && err instanceof Error ? err.message : err
|
||||
)
|
||||
logWorkItemFetchFailure('[mobile tasks] failed to fetch github work items', {
|
||||
expected: isExpectedSshSkip,
|
||||
kind: mobileLogErrorKind(err)
|
||||
})
|
||||
return {
|
||||
items: [] as Array<Extract<TaskItem, { provider: 'github' }>>,
|
||||
repoId: repo.id,
|
||||
@@ -3331,11 +3331,10 @@ export default function MobileTasksScreen({
|
||||
} catch (err) {
|
||||
const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err)
|
||||
const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn
|
||||
logWorkItemCountFailure(
|
||||
'[mobile tasks] failed to count github work items',
|
||||
repo.id,
|
||||
isExpectedSshSkip && err instanceof Error ? err.message : err
|
||||
)
|
||||
logWorkItemCountFailure('[mobile tasks] failed to count github work items', {
|
||||
expected: isExpectedSshSkip,
|
||||
kind: mobileLogErrorKind(err)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -3468,7 +3467,9 @@ export default function MobileTasksScreen({
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[mobile tasks] failed to fetch ${provider} work items`, repo.id, err)
|
||||
console.warn(`[mobile tasks] failed to fetch ${provider} work items`, {
|
||||
kind: mobileLogErrorKind(err)
|
||||
})
|
||||
return {
|
||||
items: [] as TaskItem[],
|
||||
error: err instanceof Error ? err.message : 'Failed to load GitLab tasks'
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { mobileLogErrorKind } from '../src/diagnostics/mobile-log-error-kind'
|
||||
|
||||
type Status = 'awaiting-confirm' | 'connecting' | 'error'
|
||||
|
||||
@@ -131,7 +132,7 @@ export default function PairConfirmScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] connect failed', err)
|
||||
console.warn('[pair-confirm] connect failed', { kind: mobileLogErrorKind(err) })
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { mobileLogErrorKind } from '../src/diagnostics/mobile-log-error-kind'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -175,7 +176,7 @@ export default function PairScanScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] connect failed', err)
|
||||
console.warn('[pair] connect failed', { kind: mobileLogErrorKind(err) })
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useRouter } from 'expo-router'
|
||||
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
|
||||
import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration'
|
||||
|
||||
const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop'
|
||||
|
||||
export function MobileWebRouteRestorer() {
|
||||
const router = useRouter()
|
||||
const shell = useMobileWebNativeShell()
|
||||
@@ -20,7 +18,7 @@ export function MobileWebRouteRestorer() {
|
||||
return
|
||||
}
|
||||
restoredContextRef.current = restorationKey
|
||||
router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute, HOSTED_PAGE_HOST_ID))
|
||||
router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute))
|
||||
}, [router, shell.context, shell.navigationRoute, shell.routeRevision])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileLogErrorKind } from './mobile-log-error-kind'
|
||||
|
||||
describe('mobileLogErrorKind', () => {
|
||||
it('does not expose custom error names or messages', () => {
|
||||
const error = new Error('credential-secret /private/repository')
|
||||
error.name = 'credential-secret'
|
||||
|
||||
expect(mobileLogErrorKind(error)).toBe('error')
|
||||
expect(mobileLogErrorKind('credential-secret')).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function mobileLogErrorKind(error: unknown): string {
|
||||
return error instanceof Error ? 'error' : typeof error
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from './mobile-dictation-session-state'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind'
|
||||
|
||||
type StartMobileDictationDesktopSessionOptions = {
|
||||
client: RpcClient
|
||||
@@ -99,7 +100,9 @@ export async function startMobileDictationDesktopSession(
|
||||
const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
keepAwakeOwner
|
||||
.acquire(dictationId)
|
||||
.catch((err: unknown) => console.error('Keep-awake activation failed', err))
|
||||
.catch((err: unknown) =>
|
||||
console.error('Keep-awake activation failed', { kind: mobileLogErrorKind(err) })
|
||||
)
|
||||
.finally(() => {
|
||||
clearTimeout(budgetTimer)
|
||||
resolve()
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
UseMobileDictationOptions,
|
||||
UseMobileDictationResult
|
||||
} from './mobile-dictation-session-state'
|
||||
import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind'
|
||||
|
||||
export type { UseMobileDictationResult } from './mobile-dictation-session-state'
|
||||
|
||||
@@ -68,7 +69,9 @@ export function useNativeMobileDictation(
|
||||
} catch (err) {
|
||||
// Cleanup must keep going when native recording shutdown throws, or
|
||||
// the wake tag and dictation state would leak.
|
||||
console.error('Failed to stop microphone recording', err)
|
||||
console.error('Failed to stop microphone recording', {
|
||||
kind: mobileLogErrorKind(err)
|
||||
})
|
||||
}
|
||||
void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined)
|
||||
},
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('mobile web bridge round trip', () => {
|
||||
id: `browser_0_${'01'.repeat(16)}`,
|
||||
browserPageId: `browser_0_${'01'.repeat(16)}`,
|
||||
title: 'Review',
|
||||
url: 'https://example.invalid/?credential=secret',
|
||||
url: 'https://example.invalid/',
|
||||
loading: false,
|
||||
canGoBack: true,
|
||||
canGoForward: false,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeMobileWebBrowserEvent } from './mobile-web-browser-event-sanitizer'
|
||||
|
||||
describe('sanitizeMobileWebBrowserEvent', () => {
|
||||
it('removes URL credentials from browser events', () => {
|
||||
const event = sanitizeMobileWebBrowserEvent({
|
||||
type: 'navigation',
|
||||
tab: {
|
||||
url: 'https://user:password@example.com/callback?access_token=secret&tab=review',
|
||||
title: 'Review',
|
||||
canGoBack: true,
|
||||
canGoForward: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(event).toEqual({
|
||||
type: 'navigation',
|
||||
tab: {
|
||||
url: 'https://example.com/callback?tab=review',
|
||||
title: 'Review',
|
||||
canGoBack: true,
|
||||
canGoForward: false
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(event)).not.toMatch(/password|access_token|secret/)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
MobileWebBrowserEventSchema,
|
||||
type MobileWebBrowserEvent
|
||||
} from '../../../src/shared/mobile-web/browser-operation-contract'
|
||||
import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy'
|
||||
|
||||
export function sanitizeMobileWebBrowserEvent(value: unknown): MobileWebBrowserEvent | null {
|
||||
if (!isRecord(value)) {
|
||||
@@ -12,7 +13,7 @@ export function sanitizeMobileWebBrowserEvent(value: unknown): MobileWebBrowserE
|
||||
return MobileWebBrowserEventSchema.parse({
|
||||
type: value.type,
|
||||
tab: {
|
||||
url: boundedText(tab.url, 4096, 'about:blank'),
|
||||
url: mobileWebPageBrowserUrl(tab.url),
|
||||
title: boundedText(tab.title, 240, ''),
|
||||
canGoBack: tab.canGoBack === true,
|
||||
canGoForward: tab.canGoForward === true
|
||||
|
||||
@@ -33,6 +33,26 @@ describe('mobile web browser operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('removes credentials from the authoritative navigation result', async () => {
|
||||
const { workspaceAuthority, browserAuthority, workspaceId, pageId } = authorities()
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
url: 'https://user:password@example.com/callback?code=secret&tab=review#access_token=secret'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
executeMobileWebBrowserOperation({
|
||||
operation: 'navigate',
|
||||
payload: { workspaceId, pageId, url: 'https://example.com' },
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
workspaceAuthority,
|
||||
browserAuthority
|
||||
})
|
||||
).resolves.toEqual({ url: 'https://example.com/callback?tab=review' })
|
||||
})
|
||||
|
||||
it('keeps pointer fallback native and rejects cross-workspace page handles', async () => {
|
||||
const { workspaceAuthority, browserAuthority, workspaceId, pageId } = authorities()
|
||||
const sendRequest = vi
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MobileWebBrowserPointerPayloadSchema,
|
||||
MobileWebBrowserTargetPayloadSchema
|
||||
} from '../../../src/shared/mobile-web/browser-operation-contract'
|
||||
import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
@@ -29,7 +30,7 @@ export async function executeMobileWebBrowserOperation(args: {
|
||||
)
|
||||
const result = requireResult(response)
|
||||
const parsed = MobileWebBrowserNavigateResultSchema.safeParse({
|
||||
url: isRecord(result) && typeof result.url === 'string' ? result.url : ''
|
||||
url: isRecord(result) ? mobileWebPageBrowserUrl(result.url) : 'about:blank'
|
||||
})
|
||||
if (!parsed.success) {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
|
||||
@@ -6,22 +6,26 @@ import {
|
||||
|
||||
describe('mobile web route restoration', () => {
|
||||
it('keeps the workspace list as the default recovery route', () => {
|
||||
expect(mobileWebResumeRouteTarget({ kind: 'workspaceList' }, 'paired-orca-desktop')).toBeNull()
|
||||
expect(mobileWebResumeRouteTarget({ kind: 'workspaceList' })).toBeNull()
|
||||
})
|
||||
|
||||
it('restores only the opaque workspace handle and bounded display name', () => {
|
||||
it('restores only the fixed page host label, opaque workspace handle, and display name', () => {
|
||||
expect(
|
||||
mobileWebResumeRouteTarget(
|
||||
{
|
||||
kind: 'session',
|
||||
workspaceId: 'opaque/workspace?one',
|
||||
workspaceName: 'Feature & tests'
|
||||
},
|
||||
'paired-orca-desktop'
|
||||
)
|
||||
mobileWebResumeRouteTarget({
|
||||
kind: 'session',
|
||||
workspaceId: 'opaque/workspace?one',
|
||||
workspaceName: 'Feature & tests'
|
||||
})
|
||||
).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone?name=Feature+%26+tests')
|
||||
})
|
||||
|
||||
it('cannot accept a paired host identity for a page URL', () => {
|
||||
expect(mobileWebNavigationRouteTarget).toHaveLength(1)
|
||||
expect(mobileWebNavigationRouteTarget({ kind: 'accounts' })).not.toContain(
|
||||
'paired-host-public-key'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'tasks' } as const, '/h/paired-orca-desktop/tasks'],
|
||||
[
|
||||
@@ -31,6 +35,6 @@ describe('mobile web route restoration', () => {
|
||||
[{ kind: 'accounts' } as const, '/h/paired-orca-desktop/accounts'],
|
||||
[{ kind: 'newWorkspace' } as const, '/?action=newWorktree']
|
||||
])('maps the typed native destination %s', (route, expected) => {
|
||||
expect(mobileWebNavigationRouteTarget(route, 'paired-orca-desktop')).toBe(expected)
|
||||
expect(mobileWebNavigationRouteTarget(route)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,14 @@ import type {
|
||||
MobileWebResumeRoute
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
|
||||
export function mobileWebResumeRouteTarget(
|
||||
route: MobileWebResumeRoute,
|
||||
hostedHostId: string
|
||||
): string | null {
|
||||
const target = mobileWebNavigationRouteTarget(route, hostedHostId)
|
||||
const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop'
|
||||
|
||||
export function mobileWebResumeRouteTarget(route: MobileWebResumeRoute): string | null {
|
||||
const target = mobileWebNavigationRouteTarget(route)
|
||||
return target === '/' ? null : target
|
||||
}
|
||||
|
||||
export function mobileWebNavigationRouteTarget(
|
||||
route: MobileWebNavigationRoute,
|
||||
hostedHostId: string
|
||||
): string {
|
||||
export function mobileWebNavigationRouteTarget(route: MobileWebNavigationRoute): string {
|
||||
if (route.kind === 'workspaceList') {
|
||||
return '/'
|
||||
}
|
||||
@@ -22,14 +18,14 @@ export function mobileWebNavigationRouteTarget(
|
||||
const query = route.taskSource
|
||||
? `?${new URLSearchParams({ taskSource: route.taskSource }).toString()}`
|
||||
: ''
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/tasks${query}`
|
||||
return `/h/${HOSTED_PAGE_HOST_ID}/tasks${query}`
|
||||
}
|
||||
if (route.kind === 'accounts') {
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/accounts`
|
||||
return `/h/${HOSTED_PAGE_HOST_ID}/accounts`
|
||||
}
|
||||
if (route.kind === 'newWorkspace') {
|
||||
return '/?action=newWorktree'
|
||||
}
|
||||
const query = new URLSearchParams({ name: route.workspaceName }).toString()
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/session/${encodeURIComponent(route.workspaceId)}?${query}`
|
||||
return `/h/${HOSTED_PAGE_HOST_ID}/session/${encodeURIComponent(route.workspaceId)}?${query}`
|
||||
}
|
||||
|
||||
@@ -88,6 +88,47 @@ describe('mobile web session snapshot', () => {
|
||||
).toThrow('mobile_web_session_snapshot_invalid')
|
||||
})
|
||||
|
||||
it('removes browser URL credentials and local file paths', () => {
|
||||
const authority = authorities()
|
||||
const snapshot = mobileWebSessionSnapshot(
|
||||
{
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeTabId: 'browser-1',
|
||||
activeTabType: 'browser',
|
||||
tabs: [
|
||||
{
|
||||
type: 'browser',
|
||||
id: 'browser-1',
|
||||
browserPageId: 'host-browser-1',
|
||||
title: 'Private callback',
|
||||
url: 'https://user:password@example.com/callback?token=secret&tab=review',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'browser',
|
||||
id: 'browser-2',
|
||||
browserPageId: 'host-browser-2',
|
||||
title: 'Local file',
|
||||
url: 'file:///private/repository/secret.txt',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
'workspace-1',
|
||||
'opaque-workspace',
|
||||
authority.browser,
|
||||
authority.nativeChat
|
||||
)
|
||||
|
||||
expect(snapshot.tabs.map((tab) => ('url' in tab ? tab.url : null))).toEqual([
|
||||
'https://example.com/callback?tab=review',
|
||||
'file:///[redacted]'
|
||||
])
|
||||
expect(JSON.stringify(snapshot)).not.toMatch(/password|token=|private\/repository/)
|
||||
})
|
||||
|
||||
it('projects only bounded chat state and hides host transcript authority', () => {
|
||||
const authority = authorities()
|
||||
const snapshot = mobileWebSessionSnapshot(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type MobileWebSessionSnapshotResult,
|
||||
type MobileWebSessionTab
|
||||
} from '../../../src/shared/mobile-web/bridge-operation-contract'
|
||||
import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy'
|
||||
import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
|
||||
import type {
|
||||
MobileWebHostNativeChatBinding,
|
||||
@@ -159,7 +160,7 @@ function mobileWebSessionTab(
|
||||
id: browserPageId,
|
||||
type: 'browser',
|
||||
browserPageId,
|
||||
url: boundedText(value.url, 4096, 'about:blank'),
|
||||
url: mobileWebPageBrowserUrl(value.url),
|
||||
loading: value.loading === true,
|
||||
canGoBack: value.canGoBack === true,
|
||||
canGoForward: value.canGoForward === true
|
||||
|
||||
@@ -82,7 +82,6 @@ function dropStaleResize(
|
||||
const last = context.layoutSequences.get(context.handle)
|
||||
if (last != null && eventSequence < last && last - eventSequence <= 20) {
|
||||
console.log('[fit][session] DROP-stale-seq', {
|
||||
handle: context.handle.slice(-8),
|
||||
type: data.type,
|
||||
eventSeq: eventSequence,
|
||||
lastSeq: last,
|
||||
@@ -119,7 +118,6 @@ function presentScrollback(
|
||||
const ref = context.getTerminalRef(context.handle)
|
||||
if (!ref) {
|
||||
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
|
||||
handle: context.handle.slice(-8),
|
||||
cols,
|
||||
rows
|
||||
})
|
||||
@@ -187,7 +185,6 @@ function presentOutput(
|
||||
const ref = context.getTerminalRef(context.handle)
|
||||
if (!ref) {
|
||||
console.log('[fit][session] data DROPPED — no terminal ref', {
|
||||
handle: context.handle.slice(-8),
|
||||
chunkLen: hostSessionTerminalData(data.chunk).length,
|
||||
initialized: context.initializedHandles.has(context.handle)
|
||||
})
|
||||
@@ -195,7 +192,6 @@ function presentOutput(
|
||||
}
|
||||
if (!context.initializedHandles.has(context.handle)) {
|
||||
console.log('[fit][session] data RECEIVED before scrollback', {
|
||||
handle: context.handle.slice(-8),
|
||||
chunkLen: hostSessionTerminalData(data.chunk).length
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,7 +126,13 @@ export function useMobileTerminalPaste({
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
console.warn('[mobile-clip] paste failed', {
|
||||
kind: isDisconnected
|
||||
? 'disconnected'
|
||||
: err.message === 'Clipboard image is too large'
|
||||
? 'image-too-large'
|
||||
: 'unknown'
|
||||
})
|
||||
if (isDisconnected) {
|
||||
showToast('Paste failed (disconnected)', 1500)
|
||||
} else if (err.message === 'Clipboard image is too large') {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useTerminalWebViewEngineErrorState(onEngineError?: (message: str
|
||||
(message: string, fatal: boolean) => {
|
||||
onEngineError?.(message)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[terminal-webview] engine error', message)
|
||||
console.warn('[terminal-webview] engine error', { fatal })
|
||||
if (fatal) {
|
||||
// Why: the first fatal report is the root cause; later cascades (e.g. the
|
||||
// web-ready watchdog firing after a process-crash report) must not
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createMobileDirectRpcSender } from './mobile-direct-rpc-sender'
|
||||
|
||||
describe('createMobileDirectRpcSender', () => {
|
||||
it('does not log rejected request content or error details', () => {
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const socket = { readyState: WebSocket.OPEN } as WebSocket
|
||||
const sender = createMobileDirectRpcSender({
|
||||
getOutbound: () => ({
|
||||
acknowledge: vi.fn(),
|
||||
acknowledgeAuthentication: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
enqueue() {
|
||||
const error = new Error('credential-secret /private/repository')
|
||||
error.name = 'credential-secret'
|
||||
throw error
|
||||
},
|
||||
socketClosed: vi.fn()
|
||||
}),
|
||||
getSharedKey: () => new Uint8Array(32),
|
||||
getSocket: () => socket,
|
||||
getState: () => 'connected',
|
||||
onSocketDesync: vi.fn()
|
||||
})
|
||||
|
||||
expect(sender({ token: 'request-secret' })).toBe(false)
|
||||
const output = JSON.stringify(consoleWarn.mock.calls)
|
||||
expect(output).toContain('"kind":"error"')
|
||||
expect(output).not.toMatch(/credential-secret|private\/repository|request-secret/)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound'
|
||||
import { stringifyMobileOutboundJson } from './mobile-outbound-json'
|
||||
import type { ConnectionState } from './types'
|
||||
import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind'
|
||||
|
||||
export function createMobileDirectRpcSender(args: {
|
||||
getOutbound: () => MobileDirectRpcOutbound | null
|
||||
@@ -21,7 +22,9 @@ export function createMobileDirectRpcSender(args: {
|
||||
requestAcknowledgementKey(request)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[net] outbound request rejected', error)
|
||||
console.warn('[net] outbound request rejected', {
|
||||
kind: mobileLogErrorKind(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Why: keep device tokens and full URLs out of connection logs.
|
||||
// Why: even a hostname identifies the paired desktop in shared logs.
|
||||
export function redactedWebSocketEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return (url.protocol === 'ws:' || url.protocol === 'wss:') && url.host ? url.host : 'unknown'
|
||||
if (!url.host) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (url.protocol === 'wss:') {
|
||||
return 'encrypted-websocket'
|
||||
}
|
||||
return url.protocol === 'ws:' ? 'websocket' : 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@ class NeverOpeningWebSocket {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
static latest: NeverOpeningWebSocket | null = null
|
||||
readyState = 0
|
||||
onopen: (() => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onclose: ((event: Record<string, unknown>) => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onerror: ((event: Record<string, unknown>) => void) | null = null
|
||||
constructor(readonly url: string) {
|
||||
NeverOpeningWebSocket.latest = this
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {
|
||||
this.readyState = 3
|
||||
@@ -42,7 +46,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('mobile rpc-client connection logs', () => {
|
||||
it('never exposes endpoint query credentials', () => {
|
||||
it('never exposes paired endpoint identity or 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'
|
||||
@@ -52,10 +56,12 @@ describe('mobile rpc-client connection logs', () => {
|
||||
})
|
||||
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' })
|
||||
expect.objectContaining({ message: 'Opening WebSocket', detail: 'encrypted-websocket' })
|
||||
)
|
||||
expect(JSON.stringify(logs)).not.toContain('super-secret')
|
||||
expect(JSON.stringify(logs)).not.toContain('route=private')
|
||||
const serialized = JSON.stringify(logs)
|
||||
expect(serialized).not.toContain('desktop.example')
|
||||
expect(serialized).not.toContain('super-secret')
|
||||
expect(serialized).not.toContain('route=private')
|
||||
client.close()
|
||||
})
|
||||
|
||||
@@ -67,8 +73,70 @@ describe('mobile rpc-client connection logs', () => {
|
||||
onLog: (entry) => logs.push(entry)
|
||||
})
|
||||
|
||||
expect(logs[0]?.detail).toBe('desktop.example:7443')
|
||||
expect(JSON.stringify(logs)).not.toContain('password')
|
||||
expect(logs[0]?.detail).toBe('encrypted-websocket')
|
||||
const serialized = JSON.stringify(logs)
|
||||
expect(serialized).not.toContain('desktop.example')
|
||||
expect(serialized).not.toContain('password')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not serialize endpoint, auth, or socket-event values to console', () => {
|
||||
globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const client = connect(
|
||||
'wss://private-desktop.example/runtime?token=url-secret',
|
||||
'device-secret',
|
||||
'server-key'
|
||||
)
|
||||
const socket = NeverOpeningWebSocket.latest
|
||||
if (!socket) {
|
||||
throw new Error('WebSocket was not created')
|
||||
}
|
||||
socket.onerror?.({
|
||||
endpoint: 'wss://private-desktop.example',
|
||||
message: 'socket-secret /private/repository',
|
||||
type: 'error'
|
||||
})
|
||||
socket.onclose?.({
|
||||
code: 1006,
|
||||
reason: 'close-secret /private/repository',
|
||||
wasClean: false
|
||||
})
|
||||
client.close()
|
||||
|
||||
const authClient = connect(
|
||||
'wss://private-desktop.example/runtime?token=url-secret',
|
||||
'device-secret',
|
||||
'server-key'
|
||||
)
|
||||
const authSocket = NeverOpeningWebSocket.latest
|
||||
if (!authSocket) {
|
||||
throw new Error('WebSocket was not created')
|
||||
}
|
||||
authSocket.onopen?.()
|
||||
authSocket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'e2ee_error',
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'credential-secret /private/repository'
|
||||
}
|
||||
})
|
||||
})
|
||||
authClient.close()
|
||||
|
||||
const consoleOutput = JSON.stringify(consoleLog.mock.calls)
|
||||
expect(consoleOutput).toContain('encrypted-websocket')
|
||||
for (const secret of [
|
||||
'private-desktop.example',
|
||||
'url-secret',
|
||||
'device-secret',
|
||||
'credential-secret',
|
||||
'socket-secret',
|
||||
'close-secret',
|
||||
'/private/repository'
|
||||
]) {
|
||||
expect(consoleOutput).not.toContain(secret)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,7 +279,7 @@ export function connect(
|
||||
emitLog(
|
||||
'info',
|
||||
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
|
||||
redactSocketEndpoint(endpoint)
|
||||
redactedWebSocketEndpoint(endpoint)
|
||||
)
|
||||
|
||||
if (!processMobileOutboundMemoryBudget.canRegisterBufferedAmount()) {
|
||||
@@ -455,8 +455,13 @@ export function connect(
|
||||
(!msg.ok && (msg.error as { code?: unknown } | undefined)?.code === 'unauthorized')
|
||||
) {
|
||||
openingOutbound.acknowledgeAuthentication()
|
||||
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
|
||||
clearHandshakeTimer()
|
||||
console.log('[net] e2ee auth FAILED', {
|
||||
signal: msg.type === 'e2ee_error' ? 'e2ee_error' : 'unauthorized_response'
|
||||
})
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
}
|
||||
@@ -586,6 +591,11 @@ export function connect(
|
||||
disposeActiveOutbound()
|
||||
}
|
||||
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
|
||||
const closeCode =
|
||||
typeof e?.code === 'number' && Number.isInteger(e.code) && e.code >= 0 && e.code <= 65_535
|
||||
? e.code
|
||||
: undefined
|
||||
const wasClean = typeof e?.wasClean === 'boolean' ? e.wasClean : undefined
|
||||
const closeAt = Date.now()
|
||||
// Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss.
|
||||
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
|
||||
@@ -595,9 +605,8 @@ export function connect(
|
||||
// Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
|
||||
const closeEvent = describeSocketEvent(event)
|
||||
console.log('[net] ws.onclose', {
|
||||
code: e?.code,
|
||||
reason: e?.reason,
|
||||
wasClean: e?.wasClean,
|
||||
code: closeCode,
|
||||
wasClean,
|
||||
state,
|
||||
attempt: reconnectAttempt,
|
||||
intentionallyClosed,
|
||||
@@ -605,9 +614,10 @@ export function connect(
|
||||
constructToCloseMs,
|
||||
aliveMs,
|
||||
inboundIdleMs,
|
||||
eventKeys: closeEvent.keys,
|
||||
eventStr: closeEvent.json
|
||||
eventFields: closeEvent.fields
|
||||
})
|
||||
lastWsClosedAt = closeAt
|
||||
currentWsOpenedAt = null
|
||||
handleSocketClosed(openingWs, { closeCode })
|
||||
}
|
||||
|
||||
@@ -615,15 +625,11 @@ export function connect(
|
||||
if (isStaleRpcSocketEvent(ws, openingWs, 'error', state, reconnectAttempt)) {
|
||||
return
|
||||
}
|
||||
// Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause.
|
||||
const e = event as { message?: string } | undefined
|
||||
const errEvent = describeSocketEvent(event)
|
||||
console.log('[net] ws.onerror', {
|
||||
message: e?.message,
|
||||
state,
|
||||
attempt: reconnectAttempt,
|
||||
eventKeys: errEvent.keys,
|
||||
eventStr: errEvent.json
|
||||
eventFields: errEvent.fields
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeSocketEvent } from './socket-event-debug'
|
||||
|
||||
describe('describeSocketEvent', () => {
|
||||
it('retains only reviewed field names without reading their values', () => {
|
||||
const event = {
|
||||
code: 1006,
|
||||
endpoint: 'wss://paired-desktop.example',
|
||||
message: 'credential-secret',
|
||||
reason: '/private/repository',
|
||||
type: 'error',
|
||||
wasClean: false
|
||||
}
|
||||
|
||||
expect(describeSocketEvent(event)).toEqual({
|
||||
fields: ['code', 'type', 'wasClean']
|
||||
})
|
||||
expect(JSON.stringify(describeSocketEvent(event))).not.toMatch(
|
||||
/paired-desktop|credential|private/
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when event field enumeration throws', () => {
|
||||
const event = new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys() {
|
||||
throw new Error('credential-secret')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(describeSocketEvent(event)).toEqual({ fields: [] })
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,19 @@
|
||||
// Why: RN's WebSocket close/error events are loosely typed and vary per
|
||||
// platform. Serialize them defensively (circular-safe, function-safe,
|
||||
// truncated) so the [net] diagnostics can never crash mid-handler.
|
||||
export function describeSocketEvent(event: unknown): { keys: string[]; json: string } {
|
||||
let keys: string[] = []
|
||||
const SAFE_SOCKET_EVENT_FIELDS = new Set(['code', 'isTrusted', 'type', 'wasClean'])
|
||||
|
||||
// Why: event values and extension field names can contain endpoints or host errors.
|
||||
export function describeSocketEvent(event: unknown): { fields: string[] } {
|
||||
let fields: string[] = []
|
||||
try {
|
||||
keys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
fields =
|
||||
event && typeof event === 'object'
|
||||
? Object.keys(event as object)
|
||||
.filter((key) => SAFE_SOCKET_EVENT_FIELDS.has(key))
|
||||
.sort()
|
||||
: []
|
||||
} catch {
|
||||
keys = []
|
||||
fields = []
|
||||
}
|
||||
let json = ''
|
||||
try {
|
||||
const seen = new WeakSet<object>()
|
||||
json = JSON.stringify(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) {
|
||||
return '[circular]'
|
||||
}
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') {
|
||||
return '[fn]'
|
||||
}
|
||||
return v
|
||||
},
|
||||
0
|
||||
).slice(0, 500)
|
||||
} catch {
|
||||
json = '[unstringifiable]'
|
||||
}
|
||||
return { keys, json }
|
||||
return { fields }
|
||||
}
|
||||
|
||||
export function redactSocketEndpoint(endpoint: string): string {
|
||||
|
||||
@@ -22,6 +22,20 @@ describe('mobile web browser operation contract', () => {
|
||||
url: 'javascript:alert(1)'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBrowserNavigatePayloadSchema.safeParse({
|
||||
workspaceId: 'workspace-1',
|
||||
pageId: 'browser-1',
|
||||
url: 'https://example.com/callback?access_token=secret'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBrowserNavigatePayloadSchema.safeParse({
|
||||
workspaceId: 'workspace-1',
|
||||
pageId: 'browser-1',
|
||||
url: 'file:///private/repository/secret.txt'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBrowserPointerPayloadSchema.safeParse({
|
||||
workspaceId: 'workspace-1',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { isMobileWebBase64 } from './protocol-token-contract'
|
||||
import { MobileWebWorkspaceIdSchema } from './workspace-operation-contract'
|
||||
import {
|
||||
isMobileWebPageBrowserNavigationUrl,
|
||||
MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH
|
||||
} from './browser-url-privacy'
|
||||
|
||||
export const MOBILE_WEB_BROWSER_PAGE_ID_MAX_LENGTH = 512
|
||||
export const MOBILE_WEB_BROWSER_URL_MAX_LENGTH = 4096
|
||||
export const MOBILE_WEB_BROWSER_URL_MAX_LENGTH = MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH
|
||||
export const MOBILE_WEB_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
export const MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES = 128 * 1024
|
||||
export const MOBILE_WEB_BROWSER_FRAME_MAX_CHUNKS = Math.ceil(
|
||||
@@ -47,7 +51,7 @@ export const MobileWebBrowserNavigatePayloadSchema = MobileWebBrowserTargetSchem
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MOBILE_WEB_BROWSER_URL_MAX_LENGTH)
|
||||
.refine(isAllowedBrowserUrl, 'Unsupported browser URL')
|
||||
.refine(isMobileWebPageBrowserNavigationUrl, 'Unsupported browser URL')
|
||||
}).strict()
|
||||
|
||||
export const MobileWebBrowserTargetPayloadSchema = MobileWebBrowserTargetSchema
|
||||
@@ -169,15 +173,3 @@ export type MobileWebBrowserKeyboardPayload = z.infer<typeof MobileWebBrowserKey
|
||||
export type MobileWebBrowserDialogPayload = z.infer<typeof MobileWebBrowserDialogPayloadSchema>
|
||||
export type MobileWebBrowserEvent = z.infer<typeof MobileWebBrowserEventSchema>
|
||||
export type MobileWebBrowserFrameChunk = Extract<MobileWebBrowserEvent, { type: 'frameChunk' }>
|
||||
|
||||
function isAllowedBrowserUrl(value: string): boolean {
|
||||
if (value === 'about:blank') {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMobileWebPageBrowserNavigationUrl, mobileWebPageBrowserUrl } from './browser-url-privacy'
|
||||
|
||||
describe('mobileWebPageBrowserUrl', () => {
|
||||
it.each([
|
||||
[
|
||||
'https://user:password@example.com/path?view=grid&access_token=secret#section',
|
||||
'https://example.com/path?view=grid#section'
|
||||
],
|
||||
[
|
||||
'https://example.com/callback?code=secret&state=private&tab=review',
|
||||
'https://example.com/callback?tab=review'
|
||||
],
|
||||
[
|
||||
'https://storage.example/object?x-amz-signature=secret&part=1',
|
||||
'https://storage.example/object?part=1'
|
||||
],
|
||||
[
|
||||
'https://storage.example/object?X-Goog-Credential=secret&part=1',
|
||||
'https://storage.example/object?part=1'
|
||||
],
|
||||
[
|
||||
'https://example.com/callback?id_token=secret&view=mobile',
|
||||
'https://example.com/callback?view=mobile'
|
||||
],
|
||||
['https://example.com/#access_token=secret', 'https://example.com/'],
|
||||
['https://example.com/#/callback?refresh_token=secret&view=mobile', 'https://example.com/'],
|
||||
['file:///private/repository/secret.txt', 'file:///[redacted]']
|
||||
])('removes credentials from %s', (value, expected) => {
|
||||
expect(mobileWebPageBrowserUrl(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('preserves ordinary URL state and rejects unsupported or invalid URLs', () => {
|
||||
expect(mobileWebPageBrowserUrl('https://example.com/search?q=orca#results')).toBe(
|
||||
'https://example.com/search?q=orca#results'
|
||||
)
|
||||
expect(mobileWebPageBrowserUrl('https://example.com')).toBe('https://example.com')
|
||||
expect(mobileWebPageBrowserUrl('javascript:alert(1)')).toBe('about:blank')
|
||||
expect(mobileWebPageBrowserUrl('not a url')).toBe('about:blank')
|
||||
expect(mobileWebPageBrowserUrl(`https://example.com/?q=${'a'.repeat(4096)}`)).toBe(
|
||||
'about:blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('admits only credential-free hosted navigation URLs', () => {
|
||||
expect(isMobileWebPageBrowserNavigationUrl('https://example.com/search?q=orca')).toBe(true)
|
||||
expect(isMobileWebPageBrowserNavigationUrl('about:blank')).toBe(true)
|
||||
for (const value of [
|
||||
'https://user:password@example.com/',
|
||||
'https://example.com/?token=secret',
|
||||
'https://example.com/#access_token=secret',
|
||||
'https://example.com/#/callback?refresh_token=secret',
|
||||
`https://example.com/?q=${'a'.repeat(4096)}`,
|
||||
'file:///private/repository/secret.txt',
|
||||
'javascript:alert(1)'
|
||||
]) {
|
||||
expect(isMobileWebPageBrowserNavigationUrl(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
const SENSITIVE_QUERY_KEY_PATTERNS = [
|
||||
/^auth(?:entication|orization)?$/,
|
||||
/^bearer$/,
|
||||
/^code$/,
|
||||
/^credential(?:s)?$/,
|
||||
/^csrf$/,
|
||||
/^id_token$/,
|
||||
/^oauth_state$/,
|
||||
/^password$/,
|
||||
/^passwd$/,
|
||||
/^refresh_token$/,
|
||||
/^secret$/,
|
||||
/^security_token$/,
|
||||
/^session(?:_?id)?$/,
|
||||
/^sig(?:nature)?$/,
|
||||
/^state$/,
|
||||
/^(?:access_|auth_)?token$/,
|
||||
/^api_?key$/,
|
||||
/^client_secret$/,
|
||||
/^x_amz_/,
|
||||
/^x_goog_(?:credential|signature)$/
|
||||
]
|
||||
|
||||
export const MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH = 4096
|
||||
|
||||
export function mobileWebPageBrowserUrl(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.length > MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH
|
||||
) {
|
||||
return 'about:blank'
|
||||
}
|
||||
if (value === 'about:blank') {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol === 'file:') {
|
||||
return 'file:///[redacted]'
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return 'about:blank'
|
||||
}
|
||||
if (
|
||||
!parsed.username &&
|
||||
!parsed.password &&
|
||||
!queryContainsCredential(parsed.searchParams) &&
|
||||
!fragmentContainsCredential(parsed.hash)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
parsed.username = ''
|
||||
parsed.password = ''
|
||||
for (const key of new Set(parsed.searchParams.keys())) {
|
||||
if (isSensitiveQueryKey(key)) {
|
||||
parsed.searchParams.delete(key)
|
||||
}
|
||||
}
|
||||
if (fragmentContainsCredential(parsed.hash)) {
|
||||
parsed.hash = ''
|
||||
}
|
||||
const sanitized = parsed.toString()
|
||||
return sanitized.length <= MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH ? sanitized : 'about:blank'
|
||||
} catch {
|
||||
return 'about:blank'
|
||||
}
|
||||
}
|
||||
|
||||
export function isMobileWebPageBrowserNavigationUrl(value: string): boolean {
|
||||
if (value.length > MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH) {
|
||||
return false
|
||||
}
|
||||
if (value === 'about:blank') {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
!parsed.username &&
|
||||
!parsed.password &&
|
||||
!queryContainsCredential(parsed.searchParams) &&
|
||||
!fragmentContainsCredential(parsed.hash)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isSensitiveQueryKey(key: string): boolean {
|
||||
const normalized = key.trim().toLowerCase().replaceAll('-', '_')
|
||||
return SENSITIVE_QUERY_KEY_PATTERNS.some((pattern) => pattern.test(normalized))
|
||||
}
|
||||
|
||||
function queryContainsCredential(query: URLSearchParams): boolean {
|
||||
for (const key of query.keys()) {
|
||||
if (isSensitiveQueryKey(key)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function fragmentContainsCredential(fragment: string): boolean {
|
||||
if (!fragment) {
|
||||
return false
|
||||
}
|
||||
const decoded = safelyDecodeURIComponent(fragment.slice(1)).toLowerCase()
|
||||
const keys = decoded
|
||||
.split(/[?&;]/)
|
||||
.map((part) => part.split('=', 1)[0]!.trim().replaceAll('-', '_'))
|
||||
return keys.some(isSensitiveQueryKey)
|
||||
}
|
||||
|
||||
function safelyDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user