diff --git a/cloud/.gitleaks.toml b/cloud/.gitleaks.toml index fc5c725c37d..0bb1f966fae 100644 --- a/cloud/.gitleaks.toml +++ b/cloud/.gitleaks.toml @@ -13,3 +13,10 @@ description = "Cloud SQL rollout lease holder keys in the action's unit tests" regexTarget = "secret" paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$'''] regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$'''] + +# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket +# upgrade tests; the generic key rule reads any base64 header value as a secret. +[[allowlists]] +description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests" +regexTarget = "secret" +regexes = ['''^dGhlIHNhbXBsZSBub25jZQ==$'''] diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index a04a9eea042..7aba1234f7f 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -118,6 +118,39 @@ describe('PostgreSQL schema startup', () => { expect(query).toHaveBeenCalledTimes(2) }) + it.each([ + ['42710', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42P07', 'CREATE UNIQUE INDEX IF NOT EXISTS test_index ON test(id)'] + ])('retries the committed-winner %s collision for %s', async (code, statement) => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const collision = Object.assign(new Error('already exists'), { code }) + const query = vi + .fn<(statement: string) => Promise>() + .mockRejectedValueOnce(collision) + .mockResolvedValue(undefined) + + await applyPostgresSchema([statement], query, { wait: async () => undefined }) + + expect(query).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['42710', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42710', 'CREATE TABLE test'], + ['42P07', 'CREATE TABLE test'], + ['42P07', 'CREATE INDEX test_index ON test(id)'] + ])('does not retry %s for %s', async (code, statement) => { + const error = Object.assign(new Error('already exists'), { code }) + const query = vi.fn<(statement: string) => Promise>().mockRejectedValue(error) + const pause = vi.fn(async () => undefined) + + await expect(applyPostgresSchema([statement], query, { wait: pause })).rejects.toBe(error) + + expect(pause).not.toHaveBeenCalled() + }) + it.each([ ['pg_type_typname_nsp_index', 'CREATE TABLE test'], ['pg_class_relname_nsp_index', 'CREATE INDEX test_index ON test(id)'] diff --git a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts index 5208f137ae8..9ed3cb2f324 100644 --- a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts +++ b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts @@ -34,22 +34,28 @@ describePostgres('PostgreSQL schema concurrency', () => { }) it('opens five directors when one new table is absent', async () => { - const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) - await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) - await initial.close() + // Which catalog step the race loser fails on depends on scheduling, so run several rounds and + // keep the loser's SQLSTATE in the failure instead of a bare boolean. + for (let round = 0; round < 10; round += 1) { + const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) + await initial.close() - const results = await Promise.allSettled( - Array.from({ length: 5 }, async (): Promise => - await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + const results = await Promise.allSettled( + Array.from({ length: 5 }, async (): Promise => + await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + ) + ) + const databases = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] ) - ) - const databases = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] - ) - try { - expect(results.every((result) => result.status === 'fulfilled')).toBe(true) - } finally { await Promise.all(databases.map(async (database) => await database.close())) + const rejections = results.flatMap((result) => + result.status === 'rejected' + ? [{ round, code: (result.reason as { code?: unknown }).code, message: String(result.reason) }] + : [] + ) + expect(rejections).toEqual([]) } - }) + }, 60_000) }) diff --git a/cloud/apps/relay/src/postgres-schema-startup.ts b/cloud/apps/relay/src/postgres-schema-startup.ts index 5e6260ad2fb..ba9efc6a792 100644 --- a/cloud/apps/relay/src/postgres-schema-startup.ts +++ b/cloud/apps/relay/src/postgres-schema-startup.ts @@ -22,15 +22,37 @@ function wait(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)) } +const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i +const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i + +// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent +// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by +// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines +// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt. +function concurrentCreateCollision( + value: { code?: unknown; constraint?: unknown }, + statement: string +): boolean { + if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') || + value.code === '42710' || + value.code === '42P07' + ) + } + if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') || + value.code === '42P07' + ) + } + return false +} + function retryableSchemaError(error: unknown, statement: string): boolean { const value = error as { code?: unknown; constraint?: unknown } return ( - RETRYABLE_SCHEMA_CODES.has(String(value.code)) || - (value.code === '23505' && - ((value.constraint === 'pg_type_typname_nsp_index' && - /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(statement)) || - (value.constraint === 'pg_class_relname_nsp_index' && - /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i.test(statement)))) + RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement) ) } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index a14240cfa6a..32a83962d81 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -31,6 +31,16 @@ import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' import { ProcessQueuedByteBudget } from './splice-forwarder.js' +// A malformed percent-escape in the request target must be a client error, never a URIError +// thrown out of the `upgrade` listener (which is uncaught and kills the process). +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value) + } catch { + return null + } +} + function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void { socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy() @@ -278,8 +288,8 @@ export function createRelayServer( return } if (url.pathname.startsWith('/v1/connect/')) { - const hostId = decodeURIComponent(url.pathname.slice('/v1/connect/'.length)) - if (!/^[A-Za-z0-9_-]{16}$/.test(hostId)) { + const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length)) + if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) { rejectUpgrade(socket, 429, 'Too Many Requests') return } @@ -373,7 +383,7 @@ export function createRelayServer( rejectUpgrade(socket, 404, 'Not Found') return } - const connId = decodeURIComponent(url.pathname.slice('/v1/host/data/'.length)) + const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length)) if (!connId || connId.length > 128) { rejectUpgrade(socket, 429, 'Too Many Requests') return diff --git a/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts new file mode 100644 index 00000000000..24635a68e6f --- /dev/null +++ b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts @@ -0,0 +1,122 @@ +import { connect, createServer as createNetServer } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayConfig } from './config.js' +import type { RelayDatabase } from './database.js' +import { createRelayServer } from './relay-server.js' + +async function unusedPort(): Promise { + const server = createNetServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function rawUpgrade(port: number, target: string): Promise<{ status: string; closed: boolean }> { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1') + let data = '' + socket.once('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` + + 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + // RFC 6455 §1.3 example nonce; allowlisted in cloud/.gitleaks.toml. + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n' + ) + }) + socket.on('data', (chunk) => { + data += chunk.toString() + }) + socket.once('close', () => resolve({ status: data.split('\r\n')[0] ?? '', closed: true })) + socket.once('error', reject) + setTimeout(() => { + socket.destroy() + resolve({ status: data.split('\r\n')[0] ?? '', closed: false }) + }, 1_500).unref() + }) +} + +describe('relay upgrade with a malformed request target', () => { + const cleanup: Array<() => Promise | void> = [] + + afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + }) + + it('rejects an undecodable /v1/connect path without an uncaught exception', async () => { + const port = await unusedPort() + const relayUrl = `http://127.0.0.1:${port}` + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: vi.fn(async (operation) => await operation(database)), + close: vi.fn(async () => undefined) + } + const config = { + port, + publicUrl: relayUrl, + cellUrl: relayUrl, + authIssuer: 'https://auth.example.com', + authAudience: 'orca-relay', + jwksUrl: 'https://auth.example.com/jwks', + assignmentSigningKey: new Uint8Array(32), + role: 'cell', + cellId: 'production-gce-c3', + cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }], + adminAudience: `${relayUrl}/admin`, + deployServiceAccount: 'deploy@example.com', + runtimeServiceAccount: 'runtime@example.com', + connectionHardCap: 600, + connectionUnobservedBound: 60, + adminJwksUrl: 'https://auth.example.com/admin-jwks', + databasePoolMax: 10, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + dataDir: './test-data' + } satisfies RelayConfig + const relay = createRelayServer(config, database, { + connectionLedgerLimits: { hardCap: 5, controlReserve: 1 } + }) + relay.server.listen(port, '127.0.0.1') + await new Promise((resolve) => relay.server.once('listening', resolve)) + cleanup.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Vitest installs its own uncaughtException listener; capture ours first so the test reports + // the exception as a verdict instead of dying with it. + const uncaught: unknown[] = [] + const onUncaught = (error: unknown): void => { + uncaught.push(error) + } + process.prependListener('uncaughtException', onUncaught) + cleanup.push(() => { + process.off('uncaughtException', onUncaught) + }) + + const results = [] + for (const target of [ + '/v1/connect/%', + '/v1/connect/%E0%A4%A', + '/v1/connect/%C0%AF', + '/v1/host/data/%' + ]) { + results.push(await rawUpgrade(port, target)) + } + // A malformed percent-escape must be a client error, never a process-level throw. + expect(uncaught).toEqual([]) + for (const result of results) { + expect(result.status).toMatch(/^HTTP\/1\.1 4\d\d/) + } + // The server must still serve a well-formed upgrade afterwards. + const after = await rawUpgrade(port, '/v1/connect/abcdefghijklmnop') + expect(after.status).toMatch(/^HTTP\/1\.1 101/) + }) +}) diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 57694b2033f..a10139d218f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -55,6 +55,13 @@ "dynamic": false, "count": 1 }, + { + "filePath": "src/renderer/src/components/settings/appearance-search.ts", + "kind": "object-property:keywords", + "text": "Langue", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index b6abab8001e..c6e5f434326 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -63,11 +63,11 @@ const HOST_COMPONENT_NAMES = new Set([ ]) const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' -const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272' +const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' const HEAD_CALLBACK_IDENTITY_SHA256 = - 'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc' -const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75' -const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882' + '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' +const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' +const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73' @@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - '1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887' + 'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' const HEAD_IDENTITY_FIELD_SHA256 = - '6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c' + '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' @@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(545) + expect(strings).toHaveLength(546) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index acab1d5e7c6..83b2021b95e 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource( const autoCreateHookSource = readMobileSessionRouteSource( './use-initial-session-terminal-autocreate.ts' ) +const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts') +const terminalRuntimeSource = readMobileSessionRouteSource( + './use-mobile-session-terminal-runtime.ts' +) +const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource( + './use-mobile-session-terminal-subscription.ts' +) +const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts') function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string { const start = targetSource.indexOf(startPattern) @@ -106,6 +114,19 @@ describe('mobile session startup', () => { expect(reconciliationHookSource).toContain('appStateSubscription.remove()') }) + it('binds terminal identity to the shared client before subscription effects run', () => { + expect(foundationSource).toContain('const { client, clientId, state: connState }') + expect(foundationSource).toContain(' clientId,') + expect(terminalRuntimeSource).toContain('useRef(clientId)') + expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId') + expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null') + expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)') + expect(terminalSubscriptionSourceForIdentity).toContain( + "client: { id: clientId, type: 'mobile' as const }" + ) + expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken') + }) + it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { expect(terminalSubscriptionSource).toContain( "if (data.type === 'end' || data.type === 'error')" diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 9a7889e10cb..fa2f9607bbc 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -35,7 +35,7 @@ export function useMobileSessionFoundation() { const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). - const { client, state: connState } = useHostClient(hostId) + const { client, clientId, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const forceReconnectHost = useForceReconnect() @@ -96,6 +96,7 @@ export function useMobileSessionFoundation() { router, insets, client, + clientId, connState, reconnectAttempts, lastConnectedAt, diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 912ebdb4921..7c58e84a3e5 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM connState, setCustomKeys, setVisibleBuiltInIds, - deviceTokenRef, setHostEndpoint, connStateRef, terminalRefs, @@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM unsubscribeTerminal, subscribeToTerminal } = scope - // Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity. + // Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata. useEffect(() => { if (!hostId) { return @@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM } const host = hosts.find((h) => h.id === hostId) if (host) { - deviceTokenRef.current = host.deviceToken setHostEndpoint(host.endpoint) } }) diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 8ef472fb845..5086efe1ba1 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM worktreeId, connState, client, + clientId, sessionTabs, setLiveInputCapture, liveInputTerminalHandles, @@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const terminalGestureInputInFlightRef = useRef>(new Set()) const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) - const deviceTokenRef = useRef(null) + const deviceTokenRef = useRef(clientId) + // Keep the authenticated identity synchronous with the client exposed to downstream hooks. + deviceTokenRef.current = clientId // Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear. const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) @@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM sendLiveTerminalInputRef, setLiveInputCapture }) - const { canCompose, canSend } = resolveMobileTerminalInputGate({ + const inputGate = resolveMobileTerminalInputGate({ connState, activeHandle, activeSessionTabType: activeSessionTab?.type }) + const canCompose = inputGate.canCompose + const canSend = inputGate.canSend && clientId !== null const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ activeHandleRef, diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 8ad0bb7383a..333d472a30e 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription( ) { const { client, + clientId, setTerminalModes, terminalCwdRef, - deviceTokenRef, viewportRef, viewportMeasuredRef, terminalUnsubsRef, @@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription( logSkippedGate('no-client') return } + if (clientId === null) { + logSkippedGate('no-client-identity') + return + } if (terminalUnsubsRef.current.has(handle)) { logSkippedGate('already-subscribed') return @@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription( client, { terminal: handle, - client: { id: deviceTokenRef.current!, type: 'mobile' as const }, + client: { id: clientId, type: 'mobile' as const }, viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport( covered, viewportRef.current @@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription( }, [ client, + clientId, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index a311f362f85..4a7d5d7b0e8 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -146,8 +146,8 @@ beforeEach(() => { }) describe('useHostClient', () => { - it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { - const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + it('rebinds the client and its authenticated identity together across cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' } const client1 = makeFakeClient('connected') const client2 = makeFakeClient('connected') connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) @@ -155,11 +155,13 @@ describe('useHostClient', () => { let selectedHostId = HOST.id let selectedClient: RpcClient | null = null + let selectedClientId: string | null = null let selectedState: ConnectionState = 'disconnected' let renderer: ReactTestRenderer | null = null function Probe(): null { const selected = useHostClient(selectedHostId) selectedClient = selected.client + selectedClientId = selected.clientId selectedState = selected.state useHostClient(host2.id) return null @@ -171,16 +173,18 @@ describe('useHostClient', () => { await Promise.resolve() }) expect(selectedClient).toBe(client1) + expect(selectedClientId).toBe(HOST.deviceToken) expect(selectedState).toBe('connected') selectedHostId = host2.id - client2.emitState('disconnected') await act(async () => { + client2.emitState('disconnected') renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) await Promise.resolve() }) expect(selectedClient).toBe(client2) + expect(selectedClientId).toBe(host2.deviceToken) expect(selectedState).toBe('disconnected') expect(connectMock).toHaveBeenCalledTimes(2) } finally { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index f83519940c1..76d26c1bab5 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -7,7 +7,6 @@ import { useEffect, useMemo, useRef, - useState, type ReactNode } from 'react' import type { RpcClient } from './rpc-client' @@ -35,6 +34,15 @@ import { import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' +export { + useDisconnectHostClient, + useForceReconnect, + useForgetHostClient, + useHostClient, + usePrimeHosts, + useRefreshHostClient +} from './host-client-hooks' + type StoreEntry = HostClientStoreEntry const Ctx = createContext(null) @@ -364,99 +372,3 @@ export function useRpcClientContext(): RpcClientContextValue { } return ctx } - -// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. -export function useHostClient(hostId: string | undefined): { - client: RpcClient | null - state: ConnectionState -} { - const ctx = useRpcClientContext() - const [, force] = useState(0) - // Why: an absent entry at mount is almost always the open racing the render, not a - // dead host — seed amber; a failed open notifies 'disconnected' moments later. - const [state, setState] = useState(() => - hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' - ) - const clientRef = useRef(null) - const clientHostIdRef = useRef(hostId) - const acquisitionRef = useRef({}) - - useEffect(() => { - if (!hostId) { - clientRef.current = null - clientHostIdRef.current = undefined - setState('disconnected') - return - } - clientHostIdRef.current = hostId - let cancelled = false - // Subscribe before acquire so any state change during open is captured. - const unsub = ctx.subscribeHostState(hostId, (next) => { - if (cancelled) { - return - } - setState(next) - // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one. - const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) - if (found && found.client !== clientRef.current) { - clientRef.current = found.client - force((n) => n + 1) - } else if (!found && clientRef.current) { - // Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511). - clientRef.current = null - force((n) => n + 1) - } - }) - const initial = ctx.acquire(hostId, acquisitionRef.current) - clientRef.current = initial - setState(ctx.getKnownState(hostId) ?? 'connecting') - if (initial) { - // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. - force((n) => n + 1) - } - return () => { - cancelled = true - unsub() - ctx.release(hostId, acquisitionRef.current) - clientRef.current = null - clientHostIdRef.current = undefined - } - }, [ctx, hostId]) - - // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. - const bound = clientHostIdRef.current === hostId - const boundState = bound - ? state - : hostId - ? (ctx.getKnownState(hostId) ?? 'connecting') - : 'disconnected' - return { client: bound ? clientRef.current : null, state: boundState } -} - -// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it. -export function useRefreshHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.refreshHostClient -} - -export function useForgetHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.forgetHostClient -} - -export function useDisconnectHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.disconnectHostClient -} - -// Why: future-proof "Connection issues — try again" affordance. -export function useForceReconnect(): (hostId: string) => Promise { - const ctx = useRpcClientContext() - return ctx.forceReconnect -} - -// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start. -export function usePrimeHosts(): (hosts: HostProfile[]) => void { - const ctx = useRpcClientContext() - return ctx.primeHosts -} diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 972a810af21..859e5d847f9 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -79,6 +79,7 @@ export function createHostClientSelectors( return { getKnownState, getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected', + getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null, getReconnectAttempt: (hostId: string): number => entries.get(hostId)?.client.getReconnectAttempt() ?? 0, getLastConnectedAt: (hostId: string): number | null => diff --git a/mobile/src/transport/host-client-hooks.ts b/mobile/src/transport/host-client-hooks.ts new file mode 100644 index 00000000000..c7f885034c2 --- /dev/null +++ b/mobile/src/transport/host-client-hooks.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' +import type { HostClientAcquisition } from './host-client-acquisition-registry' +import { useRpcClientContext } from './client-context' + +// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. +export function useHostClient(hostId: string | undefined): { + client: RpcClient | null + clientId: string | null + state: ConnectionState +} { + const ctx = useRpcClientContext() + const [, force] = useState(0) + const [state, setState] = useState(() => + hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' + ) + const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) + const acquisitionRef = useRef({}) + + useEffect(() => { + if (!hostId) { + clientRef.current = null + clientHostIdRef.current = undefined + setState('disconnected') + return + } + clientHostIdRef.current = hostId + let cancelled = false + const unsub = ctx.subscribeHostState(hostId, (next) => { + if (cancelled) { + return + } + setState(next) + const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) + if (found && found.client !== clientRef.current) { + clientRef.current = found.client + force((n) => n + 1) + } else if (!found && clientRef.current) { + clientRef.current = null + force((n) => n + 1) + } + }) + const initial = ctx.acquire(hostId, acquisitionRef.current) + clientRef.current = initial + setState(ctx.getKnownState(hostId) ?? 'connecting') + if (initial) { + force((n) => n + 1) + } + return () => { + cancelled = true + unsub() + ctx.release(hostId, acquisitionRef.current) + clientRef.current = null + clientHostIdRef.current = undefined + } + }, [ctx, hostId]) + + const bound = clientHostIdRef.current === hostId + const boundClient = bound ? clientRef.current : null + const boundState = bound + ? state + : hostId + ? (ctx.getKnownState(hostId) ?? 'connecting') + : 'disconnected' + return { + client: boundClient, + clientId: boundClient && hostId ? ctx.getClientId(hostId) : null, + state: boundState + } +} + +export function useRefreshHostClient(): (hostId: string) => void { + return useRpcClientContext().refreshHostClient +} + +export function useForgetHostClient(): (hostId: string) => void { + return useRpcClientContext().forgetHostClient +} + +export function useDisconnectHostClient(): (hostId: string) => void { + return useRpcClientContext().disconnectHostClient +} + +export function useForceReconnect(): (hostId: string) => Promise { + return useRpcClientContext().forceReconnect +} + +export function usePrimeHosts(): (hosts: HostProfile[]) => void { + return useRpcClientContext().primeHosts +} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 6e29f55d985..03d6363c7c7 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types' export type HostClientStoreEntry = { client: RpcClient + clientId: string state: ConnectionState refCount: number unsubState: () => void @@ -130,6 +131,7 @@ export async function openHostClientEntry( }) ?? (() => {}) const entry: HostClientStoreEntry = { client, + clientId: host.deviceToken, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, unsubState, diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index 9eb9efd4444..54e25973c7f 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -18,6 +18,7 @@ export type RpcClientContextValue = { disconnectHostClient: (hostId: string) => void getState: (hostId: string) => ConnectionState getKnownState: (hostId: string) => ConnectionState | null + getClientId: (hostId: string) => string | null getReconnectAttempt: (hostId: string) => number getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath diff --git a/src/renderer/src/components/settings/appearance-search.test.ts b/src/renderer/src/components/settings/appearance-search.test.ts index 4b8c29781c4..6169a3cccb1 100644 --- a/src/renderer/src/components/settings/appearance-search.test.ts +++ b/src/renderer/src/components/settings/appearance-search.test.ts @@ -7,14 +7,14 @@ import { matchesSettingsSearch } from './settings-search' // Native word for "language" in each supported UI language. These must be // findable no matter which locale the interface is currently rendered in, so a // speaker can locate (and switch to) their language from any starting point. -const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma'] +const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma', 'Langue'] describe('getLanguageEntries', () => { afterEach(async () => { await i18n.changeLanguage('en') }) - it.each(['en', 'zh', 'ko', 'ja', 'es'])( + it.each(['en', 'zh', 'ko', 'ja', 'es', 'fr'])( 'indexes every native word for "language" under the %s UI locale', async (locale) => { await i18n.changeLanguage(locale) @@ -29,4 +29,9 @@ describe('getLanguageEntries', () => { await i18n.changeLanguage('en') expect(matchesSettingsSearch('Español', getLanguageEntries()[0])).toBe(true) }) + + it('matches the French native language name in English UI', async () => { + await i18n.changeLanguage('en') + expect(matchesSettingsSearch('Français', getLanguageEntries()[0])).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 1886409e9e8..726ecaa1b01 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -50,6 +50,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry ...translateSearchKeyword('settings.appearance.language.korean', '한국어'), ...translateSearchKeyword('settings.appearance.language.japanese', '日本語'), ...translateSearchKeyword('settings.appearance.language.spanish', 'Español'), + ...translateSearchKeyword('settings.appearance.language.french', 'Français'), // Why: the native word for "language" only reaches search via the localized // title in its own UI locale — index each here so speakers can find (and // switch to) their language whatever the current interface locale is. @@ -58,6 +59,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry '언어', // Korean '言語', // Japanese 'Idioma', // Spanish + 'Langue', // French ...translateSearchKeyword( 'auto.components.settings.appearance.search.language.locale', 'locale' diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ed9307b30db..8eff250a820 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2934,7 +2934,6 @@ "a7e2fd2699": "signaler un problème", "5c8ce20be6": "Si le problème persiste, veuillez", "cc6d997c65": "Redémarrez le daemon de terminal depuis ici pour effacer un état de daemon obsolète.", - "7ee11bc0db": "Orca n'a pas pu confirmer si la session précédente de ce terminal tourne encore ; il a donc laissé la session intacte. Rouvrez ce volet pour réessayer.", "e16012e31e": "Le daemon de terminal propriétaire de cette session s'est arrêté ; la session et son historique de défilement n'ont pas pu être récupérés. Ouvrez un nouveau terminal pour continuer.", "sessionUnavailable": "Orca n'a pas pu se rattacher à la session de terminal de ce volet sur l'hôte. Ouvrez un nouveau terminal pour continuer." }, @@ -3228,7 +3227,6 @@ "25dc1cd653": "Ouvrir le fichier", "7cdf8ee0c8": "Ouvrir une URL", "b27864279e": "Lancer un agent", - "0e5b7a3f16": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…", "8f0a1c4d92": "Basculer vers l'onglet", "2c38630a01": "L'espace de travail n'existe plus", "4f0d9a71c2": "L'onglet n'existe plus", @@ -3274,7 +3272,6 @@ "classifier": { "42e6262ae9": "Aucune action disponible.", "097a982ee0": "Chargement des fichiers...", - "c41f8d20b7": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…", "90eb94dc48": "Saisissez une URL http:// ou https://.", "5553b283ce": "Saisissez une URL ou un chemin de fichier.", "queryTooLarge": "Le texte recherché est trop long.", @@ -6999,12 +6996,6 @@ "compareBaseRepositoryDefault": "Valeur par défaut du dépôt", "compareBaseBranchUpstream": "Amont de la branche" }, - "HiddenExperimentalGroup": { - "d0f914a528": "Bascule fictive", - "1014ddbfaf": "Sans effet aujourd'hui. Réservée comme premier emplacement pour les options expérimentales masquées.", - "232cf83de8": "Bascules non répertoriées pour les tests internes. Rien ici n'est pris en charge.", - "3e9e827ca5": "Expérimental masqué" - }, "InputPane": { "db15068196": "Activé par défaut sur Linux et macOS. Linux utilise le presse-papiers de sélection du système ; les autres plateformes utilisent un tampon privé.", "ad31c3c5fb": "Collage de la sélection au clic milieu" @@ -11832,10 +11823,6 @@ "b3c8f1a902": "Filtrer les fichiers par nom", "d4f8c2a901": "Effacer et fermer le filtre", "e8a1c4b203": "vs", - "f9b2441bb6": "1 commit d'avance sur {{value0}}", - "b715ef615b": "{{value0}} commits d'avance sur {{value1}}", - "c1a8f3e204": "1 commit de retard sur {{value0}}", - "d2b9g4f315": "{{value0}} commits de retard sur {{value1}}", "4b4a7de138": "Ouvrir la page de revue dans le navigateur", "createPrIntentCommitBlockedSummary": "Commit bloqué : {{value0}} Corrigez le problème, puis réessayez Créer une PR.", "pushRecovery": { @@ -11858,7 +11845,6 @@ "a4e93c21d7": "Branche actuelle : {{value0}}", "c7d4e2f801": "Modifier la ref de base : {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "Les détails de revue générés n'incluent pas de description. Réessayez Créer une PR.", "createPrIntentGenerateDetailsFailed": "Impossible de générer les détails de revue. Réessayez Créer une PR." }, "SourceControlAgentActionDialog": { @@ -14038,8 +14024,6 @@ "7e7ca60816": "fichiers modifiés", "b6c3b84476": "Afficher l'arborescence des fichiers", "39f8007549": "Examiner les conflits", - "39e73e7181": "ont été exclus de cette vue de diff.", - "689b99f8ad": "conflit non résolu", "820ec01f24": "Les fichiers en conflit sont examinés séparément", "fd8892b120": "Aucune modification à afficher", "eb5f40e49c": "Cette vue de diff exclut les conflits non résolus, car le pipeline de diff bidirectionnel habituel n'est pas conçu pour gérer les conflits.", @@ -15793,7 +15777,6 @@ }, "LinuxPackageInstallRecoveryCard": { "e3de29c86a": "Afficher le paquet", - "3da99454c6": "Réessayer l'installation automatique", "55c86654b7": "Copier la commande d'installation", "53e1559f99": "Échec de l'installation automatique", "a7ac6ec78b": "Orca a téléchargé la mise à jour mais n'a pas pu installer le paquet système automatiquement.",