diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 531cc61..f5e85a1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,6 +25,25 @@ permissions: contents: read jobs: + mobile-link-windows: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + github-server-url: https://gitea.twinpay.one + - name: Mobil TLS, profil ve baslatma regresyonlari + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $deps = Join-Path $env:RUNNER_TEMP "mobile-tests-$env:GITHUB_RUN_ID" + New-Item -ItemType Directory -Force -Path $deps | Out-Null + npm install --prefix $deps --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1 + if ($LASTEXITCODE -ne 0) { throw 'Test bagimliliklari kurulamadi' } + New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null + node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts + exit $LASTEXITCODE + approval-gate-windows: runs-on: windows-latest timeout-minutes: 40 diff --git a/apps/desktop/docs/mobile-profile-routing-20261007.md b/apps/desktop/docs/mobile-profile-routing-20261007.md new file mode 100644 index 0000000..5be52f6 --- /dev/null +++ b/apps/desktop/docs/mobile-profile-routing-20261007.md @@ -0,0 +1,32 @@ +# Mobile connection repair — beta.19 + +The paired phone previously resolved the desktop's active profile. If that +profile's startup promise was waiting for first-run setup, every phone API +request waited too, even while the default backend was already serving. + +REST and WebSocket requests now resolve the phone's explicit `profile` query +(default when absent). Aggregate `profile=all` reaches the default process +without rewriting the downstream query. Invalid profile identifiers, remote +backends and a missing local runtime are refused; phone requests do not install +software or silently fall back to a different profile. Resolution has an eight +second deadline; this is not an agent, tool or scheduled-job execution deadline. + +Shared-runtime completion wakes only a waiting setup for the same runtime root. +Primary startup then resolves its own profile again. This does not grant +installation consent or substitute a sibling profile's backend connection. + +## Evidence + +- 94 mobile transport, certificate, relay, profile and startup regression tests. +- Electron TypeScript type check. +- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local + backend behind the production TLS proxy, pinned certificate, screen reads, + cron create/update/pause/resume/delete and chat WebSocket ping. Test-created + job is deleted; existing jobs are untouched. This test does not trigger an + agent job or claim physical-phone/cellular acceptance. +- Public relay hello was separately exercised using Android's production + OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness. + +Publication, installed beta.19 and physical-phone acceptance are separate gates. +Existing phone security exclusions are unchanged. Unsupported methods and +features are not claimed to be implemented by this connection repair. diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts index f8b5352..8fa7acc 100644 --- a/apps/desktop/electron/first-run-setup-gate.test.ts +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () => assert.equal(hidden, 0) assert.equal(gate.isLocalBootstrapConfirmed(), true) }) + +test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false) + assert.equal(await settledState(pending), 'pending') + assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true) + assert.equal(await pending, 'runtime-ready') + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(hidden, 1) + assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false) +}) + +test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + gate.resetForRetry() + assert.equal(await pending, 'reset') + assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false) +}) + +test('runtime completed before wait does not leave a stale setup prompt pending', async () => { + let prompts = 0 + const gate = createFirstRunSetupGate({ + isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot, + promptChoice: () => prompts++, + stuckAfterMs: 0 + }) + assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready') + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(prompts, 0) +}) diff --git a/apps/desktop/electron/first-run-setup-gate.ts b/apps/desktop/electron/first-run-setup-gate.ts index 2d83cbe..89dd8f4 100644 --- a/apps/desktop/electron/first-run-setup-gate.ts +++ b/apps/desktop/electron/first-run-setup-gate.ts @@ -1,3 +1,5 @@ +import path from 'node:path' + interface FirstRunSetupBackend { activeRoot?: string kind?: string @@ -5,6 +7,7 @@ interface FirstRunSetupBackend { } interface FirstRunSetupGateOptions { + isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean hideChoice?: () => void log?: (message: string) => void onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void @@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions { stuckAfterMs?: number } -export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' +export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready' export function createFirstRunSetupGate({ + isRuntimeAvailable, hideChoice, log, onStuck, @@ -24,6 +28,7 @@ export function createFirstRunSetupGate({ let localBootstrapConfirmed = false let waiter: { + activeRoot?: string promise: Promise resolve: (decision: FirstRunSetupDecision) => void } | null = null @@ -65,6 +70,13 @@ export function createFirstRunSetupGate({ return 'continue-local' as const } + // Recheck synchronously before registering the waiter: another profile + // may have completed installation before this stale descriptor arrived. + if (backend && isRuntimeAvailable?.(backend)) { + if (backend.activeRoot) runtimeAvailable(backend.activeRoot) + return 'runtime-ready' as const + } + if (waiter) { return waiter.promise } @@ -78,7 +90,7 @@ export function createFirstRunSetupGate({ resolveWaiter = resolve }) - waiter = { promise, resolve: resolveWaiter } + waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter } return promise } @@ -102,6 +114,21 @@ export function createFirstRunSetupGate({ settleWaiter('continue-local') } + // A sibling profile may finish installing the SAME shared runtime while the + // primary boot is awaiting a setup choice. Re-resolve it; do not interpret + // this as permission for another installation, or reuse the sibling's + // profile/backend descriptor. + const runtimeAvailable = (root: string) => { + const canonical = (value: string) => { + const absolute = path.resolve(value) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute + } + if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false + settleWaiter('runtime-ready') + hideChoice?.() + return true + } + const resetForRetry = () => { // Reset paths are followed by a renderer reload / fresh startHermes() call. // Settle the old boot explicitly so it cannot fall through into local @@ -140,6 +167,7 @@ export function createFirstRunSetupGate({ isLocalBootstrapConfirmed, resetForRepair, resetForRetry, + runtimeAvailable, shouldGate, wait } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8f2b83f..a9817fe 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' +import { createMobileBackendResolver } from './mobile-backend' import { createMobileLink, createMobileLinkFileStore, @@ -2075,6 +2076,10 @@ function hideFirstRunSetupChoice() { function getFirstRunSetupGate() { if (!firstRunSetupGate) { firstRunSetupGate = createFirstRunSetupGate({ + isRuntimeAvailable: backend => + backend.activeRoot === ACTIVE_HERMES_ROOT && + resolveHermesBackend(['--profile', primaryProfileKey(), 'serve', '--host', '127.0.0.1', '--port', '0']).kind !== + 'bootstrap-needed', hideChoice: hideFirstRunSetupChoice, log: rememberLog, onStuck: (_backend, stuckAfterMs) => { @@ -5259,6 +5264,11 @@ async function ensureRuntime(backend) { error: null }) + // Runtime installation is shared; profile processes and their data are not. + // Wake only a setup waiter for this exact installed root, then let primary + // startup resolve its own profile again (never adopt a pool connection). + firstRunSetupGate?.runtimeAvailable(ACTIVE_HERMES_ROOT) + return backend } @@ -6795,7 +6805,7 @@ function buildApplicationMenu() { const checkForUpdatesItem = { label: 'Check for Updates…', - click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested() + click: () => (IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()) } if (IS_MAC) { @@ -17646,8 +17656,11 @@ async function getUninstallSummary() { async function runDesktopUninstall(_mode) { // The upstream cleanup script removes Hermes-specific shortcuts, registry // entries and global launchers. AITURK is owned by its OS package manager. - return { ok: false, error: 'aiturk-os-uninstaller', - message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' } + return { + ok: false, + error: 'aiturk-os-uninstaller', + message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' + } } ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary()) @@ -17853,19 +17866,13 @@ function getMobileLink(): MobileLink { // Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept. vault: new MobileVault(directory, safeStorage), store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises), - resolveBackend: async () => { - if (mobileLinkRemoteMode()) { - throw new Error('aiturk-mobile:remote_mode') - } - - const connection = await ensureBackend(primaryProfileKey()) - - if (connection.mode !== 'local') { - throw new Error('aiturk-mobile:remote_mode') - } - - return { baseUrl: connection.baseUrl, token: connection.token } - }, + resolveBackend: createMobileBackendResolver({ + isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile), + runtimeNeedsInstall: profile => + resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind === + 'bootstrap-needed', + ensureBackend + }), isRemoteMode: mobileLinkRemoteMode, firewall: createWindowsFirewall({ spawn, @@ -17875,11 +17882,20 @@ function getMobileLink(): MobileLink { // console code page; latin1 keeps every byte and the parser needs no translated word. run: (file, args) => new Promise(resolve => { - execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => { - const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0 + execFile( + file, + args, + { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, + (error, stdout) => { + const code = error + ? typeof (error as { code?: unknown }).code === 'number' + ? ((error as { code?: unknown }).code as number) + : null + : 0 - resolve({ code, stdout: String(stdout ?? '') }) - }) + resolve({ code, stdout: String(stdout ?? '') }) + } + ) }) }), fetch: (url, init) => fetch(url, init as RequestInit), @@ -17939,7 +17955,12 @@ app.whenReady().then(() => { } return ensureBackend(profile) } - }).then(stop => app.once('will-quit', () => { void stop() })) + }) + .then(stop => + app.once('will-quit', () => { + void stop() + }) + ) .catch(() => rememberLog('[chrome] Native bridge could not start.')) } // Warm the login-shell PATH resolution immediately so it usually completes diff --git a/apps/desktop/electron/mobile-backend.test.ts b/apps/desktop/electron/mobile-backend.test.ts new file mode 100644 index 0000000..e6faa4d --- /dev/null +++ b/apps/desktop/electron/mobile-backend.test.ts @@ -0,0 +1,48 @@ +import { expect, it, vi } from 'vitest' + +import { createMobileBackendResolver } from './mobile-backend' + +const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' } + +it('uses the phone profile even while another desktop profile is awaiting setup', async () => { + const ensureBackend = vi.fn(async (profile: string) => { + if (profile === 'fusion-frontier') return new Promise(() => {}) + return local + }) + const resolve = createMobileBackendResolver({ + isRemote: () => false, + runtimeNeedsInstall: () => false, + ensureBackend + }) + expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token }) + expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default') +}) + +it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => { + const ensureBackend = vi.fn(async () => local) + const resolve = createMobileBackendResolver({ + isRemote: profile => profile === 'remote', + runtimeNeedsInstall: () => true, + ensureBackend + }) + await expect(resolve('default')).rejects.toThrow('runtime_not_ready') + await expect(resolve('remote')).rejects.toThrow('remote_mode') + expect(ensureBackend).not.toHaveBeenCalled() +}) + +it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => { + let remote = false + const ensureBackend = vi.fn(async () => local) + const resolve = createMobileBackendResolver({ + isRemote: () => remote, + runtimeNeedsInstall: () => false, + ensureBackend + }) + await resolve('kodlama') + expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama') + ensureBackend.mockImplementationOnce(async () => { + remote = true + return local + }) + await expect(resolve('kodlama')).rejects.toThrow('remote_mode') +}) diff --git a/apps/desktop/electron/mobile-backend.ts b/apps/desktop/electron/mobile-backend.ts new file mode 100644 index 0000000..b3d624f --- /dev/null +++ b/apps/desktop/electron/mobile-backend.ts @@ -0,0 +1,18 @@ +/** Mobile routing is explicit and cannot start a first-run installer. */ +export function createMobileBackendResolver({ + isRemote, + runtimeNeedsInstall, + ensureBackend +}: { + isRemote: (profile: string) => boolean + runtimeNeedsInstall: (profile: string) => boolean + ensureBackend: (profile: string) => Promise +}) { + return async (profile: string) => { + if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode') + if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready') + const connection = await ensureBackend(profile) + if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode') + return { baseUrl: connection.baseUrl, token: connection.token } + } +} diff --git a/apps/desktop/electron/mobile-lan-proxy.test.ts b/apps/desktop/electron/mobile-lan-proxy.test.ts index 4f587bb..db78da4 100644 --- a/apps/desktop/electron/mobile-lan-proxy.test.ts +++ b/apps/desktop/electron/mobile-lan-proxy.test.ts @@ -46,7 +46,10 @@ import { const SESSION = crypto.randomBytes(32).toString('base64url') const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']) -const hostOnly = (value: unknown) => String(value || '').replace(/:\d+$/, '').toLowerCase() +const hostOnly = (value: unknown) => + String(value || '') + .replace(/:\d+$/, '') + .toLowerCase() const seen: { path: string; host: string; raw: string; method: string }[] = [] const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ') @@ -90,7 +93,12 @@ beforeAll(async () => { } } - seen.push({ path: req.url ?? '', host: String(req.headers.host), method: String(req.method), raw: JSON.stringify(req.headers) + req.url }) + seen.push({ + path: req.url ?? '', + host: String(req.headers.host), + method: String(req.method), + raw: JSON.stringify(req.headers) + req.url + }) send(200, JSON.stringify({ ok: true, path: url.pathname })) }) @@ -143,48 +151,50 @@ interface CallOptions { } function call(port: number, method: string, path: string, options: CallOptions = {}) { - return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>((resolve, reject) => { - const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) + return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>( + (resolve, reject) => { + const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) - const req = https.request( - { - host: '127.0.0.1', - port, - method, - path, - agent: false, - ...(options.ca ? { ca: options.ca } : {}), - checkServerIdentity: () => undefined, - headers: { - ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), - ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), - ...(payload ? { 'content-type': 'application/json' } : {}), - origin: 'http://evil.example', - cookie: 'session=evil', - ...options.headers - } - }, - res => { - let text = '' - - res.on('data', chunk => (text += chunk)) - res.on('end', () => { - let json: any = null - - try { - json = JSON.parse(text) - } catch { - json = null + const req = https.request( + { + host: '127.0.0.1', + port, + method, + path, + agent: false, + ...(options.ca ? { ca: options.ca } : {}), + checkServerIdentity: () => undefined, + headers: { + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), + ...(payload ? { 'content-type': 'application/json' } : {}), + origin: 'http://evil.example', + cookie: 'session=evil', + ...options.headers } + }, + res => { + let text = '' - resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json }) - }) - } - ) + res.on('data', chunk => (text += chunk)) + res.on('end', () => { + let json: any = null - req.on('error', reject) - req.end(payload) - }) + try { + json = JSON.parse(text) + } catch { + json = null + } + + resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json }) + }) + } + ) + + req.on('error', reject) + req.end(payload) + } + ) } function wsOpen(url: string, ca: string, extra: Record = {}) { @@ -277,7 +287,10 @@ describe('LAN proxy: pairing (hello / claim)', () => { it('claim is 410 pairing_closed while pairing is not open', async () => { const t = await startProxy() - const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: 'AAAAAAAA', device_name: 'x' } }) + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + body: { code: 'AAAAAAAA', device_name: 'x' } + }) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) await t.proxy.stop() @@ -288,13 +301,19 @@ describe('LAN proxy: pairing (hello / claim)', () => { const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { - const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + body: { code: wrongCode(code), device_name: 'x' } + }) expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }]) } const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} ` - const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: typed, device_name: ' Pixel\u0000 9 ' } }) + const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + body: { code: typed, device_name: ' Pixel\u0000 9 ' } + }) expect(ok.status).toBe(200) expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version']) @@ -326,10 +345,20 @@ describe('LAN proxy: pairing (hello / claim)', () => { const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { - expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })).status).toBe(401) + expect( + ( + await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + body: { code: wrongCode(code), device_name: 'x' } + }) + ).status + ).toBe(401) } - const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) + const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + body: { code: wrongCode(code), device_name: 'x' } + }) expect([fifth.status, fifth.json]).toEqual([410, { error: 'pairing_locked' }]) @@ -358,16 +387,28 @@ describe('LAN proxy: pairing (hello / claim)', () => { const t = await startProxy() const { code } = t.proxy.beginPairing() - for (const rawBody of ['{not json', '[]', '{"code":12345678,"device_name":"x"}', `{"code":"${code}"}`, `{"code":"${code}","device_name":""}`, JSON.stringify({ code, device_name: 'n'.repeat(61) })]) { + for (const rawBody of [ + '{not json', + '[]', + '{"code":12345678,"device_name":"x"}', + `{"code":"${code}"}`, + `{"code":"${code}","device_name":""}`, + JSON.stringify({ code, device_name: 'n'.repeat(61) }) + ]) { const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody }) expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }]) } - const big = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) }) + const big = await call(t.port, 'POST', '/aiturk/pair/claim', { + ca: t.ca, + rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) + }) expect(big.status).toBe(413) - expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status).toBe(200) + expect( + (await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status + ).toBe(200) await t.proxy.stop() }) }) @@ -421,7 +462,11 @@ describe('LAN proxy: REST', () => { for (const options of [{ token }, { headerToken: token }]) { seen.length = 0 - const r = await call(t.port, 'GET', '/api/sessions?limit=20', { ca: t.ca, ...options, headers: { 'x-forwarded-for': '10.0.0.9' } }) + const r = await call(t.port, 'GET', '/api/sessions?limit=20', { + ca: t.ca, + ...options, + headers: { 'x-forwarded-for': '10.0.0.9' } + }) expect(r.status).toBe(200) expect(r.json).toEqual({ ok: true, path: '/api/sessions' }) @@ -498,7 +543,11 @@ describe('LAN proxy: REST', () => { resolveCalls = 0 for (const [method, path] of forbidden) { - const r = await call(t.port, method, path, { ca: t.ca, token, ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) }) + const r = await call(t.port, method, path, { + ca: t.ca, + token, + ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) + }) expect([method, path, r.status, r.json]).toEqual([method, path, 403, { error: 'forbidden' }]) } @@ -527,6 +576,50 @@ describe('LAN proxy: REST', () => { await t.proxy.stop() }) + it('routes REST and WebSocket by the phone profile, never the desktop selection', async () => { + const profiles: string[] = [] + const t = await startProxy({ + resolveBackend: async (profile: string) => { + profiles.push(profile) + return resolveBackend() + } + }) + try { + const { token } = await t.pair() + for (const query of ['', '?profile=kodlama', '?profile=all']) { + expect((await call(t.port, 'GET', `/api/cron/jobs${query}`, { ca: t.ca, token })).status).toBe(200) + } + const opened = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}&profile=seo`, t.ca) + expect(opened.open).toBe(true) + opened.ws?.close() + expect(profiles).toEqual(['default', 'kodlama', 'default', 'seo']) + expect(seen.some(row => row.path === '/api/cron/jobs?profile=all')).toBe(true) + } finally { + await t.proxy.stop() + } + }) + + it('bounds a pending setup/backend resolution for REST and WebSocket and never forwards the late result', async () => { + let finish: (value: { baseUrl: string; token: string }) => void = () => {} + const pending = new Promise<{ baseUrl: string; token: string }>(resolve => { + finish = resolve + }) + const t = await startProxy({ resolveBackend: () => pending, backendResolveTimeoutMs: 40 }) + try { + const { token } = await t.pair() + const before = seen.length + const rest = await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token }) + expect([rest.status, rest.json]).toEqual([503, { error: 'backend_unavailable' }]) + expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca)).status).toBe(503) + finish({ baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION }) + await new Promise(resolve => setTimeout(resolve, 30)) + expect(seen.length).toBe(before) + expect((await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token })).status).toBe(200) + } finally { + await t.proxy.stop() + } + }) + it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => { const remote = await startProxy({ resolveBackend: async () => { @@ -541,7 +634,9 @@ describe('LAN proxy: REST', () => { expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503) await remote.proxy.stop() - const elsewhere = await startProxy({ resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) }) + const elsewhere = await startProxy({ + resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) + }) const b = await elsewhere.pair() expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503) @@ -628,10 +723,18 @@ describe('LAN proxy: TLS, listening and logs', () => { const t = await startProxy() const peer = await new Promise((resolve, reject) => { - const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, res => { - resolve(crypto.createHash('sha256').update((res.socket as any).getPeerCertificate(true).raw).digest('base64url')) - res.resume() - }) + const req = https.request( + { host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, + res => { + resolve( + crypto + .createHash('sha256') + .update((res.socket as any).getPeerCertificate(true).raw) + .digest('base64url') + ) + res.resume() + } + ) req.on('error', reject) req.end() @@ -641,10 +744,14 @@ describe('LAN proxy: TLS, listening and logs', () => { expect(peer).not.toBe(createSelfSignedCertificate().fingerprint) // A phone pinned to ANOTHER certificate must refuse this server. - await expect(call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert })).rejects.toThrow() + await expect( + call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert }) + ).rejects.toThrow() const systemCa = await new Promise(resolve => { - const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => resolve('accepted')) + const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => + resolve('accepted') + ) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) req.end() @@ -653,7 +760,9 @@ describe('LAN proxy: TLS, listening and logs', () => { expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/) const plain = await new Promise(resolve => { - const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => resolve(String(res.statusCode))) + const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => + resolve(String(res.statusCode)) + ) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error'))) req.end() @@ -710,7 +819,11 @@ describe('LAN proxy: TLS, listening and logs', () => { // Stand-in for the LAN bind (production: 0.0.0.0). Still loopback, so no test socket leaves this machine, // but it is a DIFFERENT address from 127.0.0.1, which is what "listening on the LAN or not" needs. const LAN_HOST = '127.0.0.2' -const RELAY_IDS = ['3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f'] +const RELAY_IDS = [ + '3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', + '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', + '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f' +] const HOST_TOKENS = RELAY_IDS.map((_id, index) => `tsmr_${String.fromCharCode(65 + index).repeat(43)}`) /** Host socket that opens at once and never carries traffic; enough for the controller tests. */ @@ -835,7 +948,11 @@ function linkHarness(over: Record = {}) { if (isOffer) { return { status: offerOutcome, - json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 }) + json: async () => ({ + offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', + cancelToken: `tsmp_${'c'.repeat(43)}`, + expiresIn: 300 + }) } } @@ -886,14 +1003,35 @@ function linkHarness(over: Record = {}) { const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query') const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers')) - return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries } + return { + link: make(), + make, + vault, + store, + firewallCalls, + firewallWrites, + fetchCalls, + offers, + events, + logs, + state, + hostSockets, + retrySleeps, + heldRetries + } } describe('mobile link controller', () => { it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => { const h = linkHarness() - expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, port: null, pairing: null, devices: [] }) + expect(await h.link.state()).toMatchObject({ + enabled: false, + listening: false, + port: null, + pairing: null, + devices: [] + }) const enabled = await h.link.enable() const port = enabled.port as number @@ -953,7 +1091,10 @@ describe('mobile link controller', () => { expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code) const ca = h.vault.value.lanTls.cert - const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: pairing.code, device_name: 'Pixel 9' } }) + const claim = await call(port, 'POST', '/aiturk/pair/claim', { + ca, + body: { code: pairing.code, device_name: 'Pixel 9' } + }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) @@ -1033,7 +1174,10 @@ describe('mobile link controller', () => { const state = await h.link.pairBegin() const port = state.port as number const ca = h.vault.value.lanTls.cert - const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) + const claim = await call(port, 'POST', '/aiturk/pair/claim', { + ca, + body: { code: state.pairing!.code, device_name: 'Pixel' } + }) await until(() => h.events.some(event => event.type === 'paired')) @@ -1083,10 +1227,19 @@ describe('mobile link controller', () => { }) it('"İzin ver" is the only thing that adds the firewall rule; a refused UAC prompt leaves direct access off', async () => { - const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] }) + const h = linkHarness({ + firewallResults: [ + { ok: false, reason: 'denied' }, + { ok: false, reason: 'failed' } + ] + }) const state = await h.link.enable() - expect(state).toMatchObject({ enabled: true, firewall: 'needed', lan: { direct: false, listening: false, firewall: 'missing' } }) + expect(state).toMatchObject({ + enabled: true, + firewall: 'needed', + lan: { direct: false, listening: false, firewall: 'missing' } + }) expect(h.firewallWrites()).toEqual([]) expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } }) expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } }) @@ -1095,7 +1248,10 @@ describe('mobile link controller', () => { // The rule is there (asked from Windows, not remembered): no second prompt. expect((await h.link.lanAllow()).firewall).toBe('ok') expect(h.firewallWrites()).toHaveLength(3) - expect(await h.link.firewallRemove()).toMatchObject({ firewall: 'needed', lan: { direct: false, firewall: 'missing' } }) + expect(await h.link.firewallRemove()).toMatchObject({ + firewall: 'needed', + lan: { direct: false, firewall: 'missing' } + }) expect(h.firewallWrites().at(-1)).toBe('remove') await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled') await h.link.stop() @@ -1113,7 +1269,10 @@ describe('mobile link controller', () => { const port = first.port as number // Listening, but only on loopback: the stand-in LAN address refuses the connection. - expect(first).toMatchObject({ listening: true, lan: { direct: false, listening: false, port, firewall: 'missing' } }) + expect(first).toMatchObject({ + listening: true, + lan: { direct: false, listening: false, port, firewall: 'missing' } + }) expect(await connectOutcome(port, '127.0.0.1')).toBe('open') expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED') expect(first.pairing!.qr).not.toContain('u=') @@ -1124,7 +1283,11 @@ describe('mobile link controller', () => { const allowed = await h.link.lanAllow() expect(h.firewallWrites()).toEqual([`add:${port}`]) - expect(allowed).toMatchObject({ pairing: null, listening: false, lan: { direct: true, listening: false, firewall: 'allowed' } }) + expect(allowed).toMatchObject({ + pairing: null, + listening: false, + lan: { direct: true, listening: false, firewall: 'allowed' } + }) expect(h.store.value.lanDirect).toBe(true) const second = await h.link.pairBegin() @@ -1168,7 +1331,14 @@ describe('mobile link controller', () => { const h = linkHarness({ rule: { state: 'present', ports: [47321] }, - stored: { v: 1, enabled: true, port: null, firewallPort: 47321, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] } + stored: { + v: 1, + enabled: true, + port: null, + firewallPort: 47321, + fingerprint: null, + devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] + } }) const resumed = await h.link.start() @@ -1189,7 +1359,11 @@ describe('mobile link controller', () => { await h.link.enable() await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/) - expect(await h.link.state()).toMatchObject({ pairing: null, listening: false, relay: { ready: false, status: 'off' } }) + expect(await h.link.state()).toMatchObject({ + pairing: null, + listening: false, + relay: { ready: false, status: 'off' } + }) expect(h.offers()).toEqual([]) // Direct access on, but no usable LAN address either. @@ -1201,7 +1375,9 @@ describe('mobile link controller', () => { const state = await h.link.pairBegin() - expect(state.pairing!.qr).toMatch(new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`)) + expect(state.pairing!.qr).toMatch( + new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`) + ) await h.link.stop() const broken = createMobileLink({ @@ -1252,7 +1428,10 @@ describe('mobile link controller', () => { expect(h.vault.value.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) expect(h.vault.value.accountToken).toBe('eski-alan') - const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) + const claim = await call(port, 'POST', '/aiturk/pair/claim', { + ca, + body: { code: state.pairing!.code, device_name: 'Pixel' } + }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) @@ -1263,10 +1442,16 @@ describe('mobile link controller', () => { // "Röle kimliğini yenile". const renewed = await h.link.relayRenew() - expect(renewed).toMatchObject({ devices: [], listening: false, relay: { status: 'off', ready: true, reissued: true } }) + expect(renewed).toMatchObject({ + devices: [], + listening: false, + relay: { status: 'off', ready: true, reissued: true } + }) expect(h.store.value.relayId).toBe(RELAY_IDS[1]) expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[1]) - expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status).not.toBe(200) + expect( + (await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status + ).not.toBe(200) await until(() => h.hostSockets[0].closed !== null) const again = await h.link.pairBegin() @@ -1275,7 +1460,9 @@ describe('mobile link controller', () => { await until(() => h.events.at(-1)?.state.relay.status === 'connected') // Every socket after the renewal carries the new token; the old one is never sent again. expect(h.hostSockets.length).toBeGreaterThan(1) - expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe(true) + expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe( + true + ) expect(h.hostSockets.at(-1)!.url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[1]}`) // Renewal that cannot reach the gateway changes nothing. @@ -1284,11 +1471,22 @@ describe('mobile link controller', () => { expect(h.store.value.relayId).toBe(RELAY_IDS[1]) // The host token never reaches a log line, an event, a state answer or the plain store. - const exposed = [h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value)].join('\n') + const exposed = [ + h.logs.join('\n'), + JSON.stringify(h.events), + JSON.stringify(await h.link.state()), + JSON.stringify(h.store.value) + ].join('\n') expect(exposed).not.toContain('tsmr_') - expect(h.logs.filter(line => line.startsWith('relay '))).toEqual(expect.arrayContaining(['relay connecting', 'relay connected', 'relay off'])) - expect(h.logs.filter(line => line.startsWith('relay ')).every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true) + expect(h.logs.filter(line => line.startsWith('relay '))).toEqual( + expect.arrayContaining(['relay connecting', 'relay connected', 'relay off']) + ) + expect( + h.logs + .filter(line => line.startsWith('relay ')) + .every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line)) + ).toBe(true) await h.link.stop() }) @@ -1299,7 +1497,10 @@ describe('mobile link controller', () => { const state = await h.link.pairBegin() - await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) + await call(state.port as number, 'POST', '/aiturk/pair/claim', { + ca: h.vault.value.lanTls.cert, + body: { code: state.pairing!.code, device_name: 'Pixel' } + }) await until(() => h.events.some(event => event.type === 'paired')) await h.link.stop() @@ -1453,7 +1654,13 @@ describe('mobile link controller: rendezvous record retries', () => { const again = (await h.link.pairRepublish()).pairing! - expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' }) + expect(again).toMatchObject({ + code: begun.code, + qr: begun.qr, + expiresAt: begun.expiresAt, + securityCode: begun.securityCode, + codePath: 'pending' + }) await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready') expect(h.offers()).toHaveLength(2) expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) @@ -1501,7 +1708,13 @@ describe('mobile link controller: rendezvous record retries', () => { }) describe('publishPairOffer / cancelPairOffer', () => { - const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] } + const input = { + code: 'ABCDEFGH', + addresses: [] as string[], + port: 47321, + fingerprint: 'f'.repeat(43), + relay: RELAY_IDS[0] + } const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` } function plan(outcomes: (number | 'throw')[]) { @@ -1577,7 +1790,10 @@ describe('publishPairOffer / cancelPairOffer', () => { const told = plan([409]) - expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null }) + expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ + offerId: null, + cancelToken: null + }) }) it('stops as soon as the pairing is over, without a log line', async () => { @@ -1624,12 +1840,18 @@ describe('pure helpers', () => { expect(normalizePairCode(' abcd-efgh ')).toBe('ABCDEFGH') expect(normalizePairCode(12345678)).toBe('') expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH') - expect(pairLookup('abcd-efgh')).toBe(crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex')) + expect(pairLookup('abcd-efgh')).toBe( + crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex') + ) }) it('address candidates: private IPv4 only, default route first, virtual adapters dropped, at most four', () => { expect(['10.1.2.3', '172.16.0.1', '172.31.255.254', '192.168.0.1'].every(isPrivateIpv4)).toBe(true) - expect(['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some(isPrivateIpv4)).toBe(false) + expect( + ['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some( + isPrivateIpv4 + ) + ).toBe(false) const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }] @@ -1646,13 +1868,25 @@ describe('pure helpers', () => { 'Ethernet 4': v4('192.168.4.2') } - expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual(['192.168.1.20', '10.0.0.5', '192.168.2.2', '192.168.3.2']) + expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual([ + '192.168.1.20', + '10.0.0.5', + '192.168.2.2', + '192.168.3.2' + ]) expect(selectLanAddresses(interfaces, null)[0]).toBe('10.0.0.5') expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([]) - expect(() => buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toThrow( - 'aiturk-mobile:no_lan_address' - ) - expect(buildPairQrPayload({ addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toBe( + expect(() => + buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' }) + ).toThrow('aiturk-mobile:no_lan_address') + expect( + buildPairQrPayload({ + addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], + port: 47321, + fingerprint: 'f'.repeat(43), + code: 'ABCDEFGH' + }) + ).toBe( `aiturk://pair?v=1&u=10.0.0.1:47321&u=10.0.0.2:47321&u=10.0.0.3:47321&u=10.0.0.4:47321&fp=${'f'.repeat(43)}&c=ABCDEFGH` ) }) @@ -1683,7 +1917,11 @@ describe('pure helpers', () => { expect(() => firewallCommand('add', bad)).toThrow('aiturk-mobile:invalid_port') } - for (const [exit, expected] of [[0, { ok: true }], [77, { ok: false, reason: 'denied' }], [1, { ok: false, reason: 'failed' }]] as const) { + for (const [exit, expected] of [ + [0, { ok: true }], + [77, { ok: false, reason: 'denied' }], + [1, { ok: false, reason: 'failed' }] + ] as const) { const spawned: any[] = [] const firewall = createWindowsFirewall({ @@ -1725,7 +1963,11 @@ describe('pure helpers', () => { parseMobileLinkStored({ enabled: 'yes', port: 99999, - devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null] + devices: [ + { id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, + { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, + null + ] }) ).toEqual({ enabled: false, @@ -1748,8 +1990,12 @@ describe('pure helpers', () => { relayReissued: false }) // Only an explicit `true` turns direct LAN access on; a relay id must be a uuid. - expect(parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 })).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false }) - expect(parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })).toMatchObject({ + expect( + parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 }) + ).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false }) + expect( + parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true }) + ).toMatchObject({ lanDirect: true, relayId: RELAY_IDS[0], relayReissued: true @@ -1760,16 +2006,30 @@ describe('pure helpers', () => { const fp = 'f'.repeat(43) const relay = RELAY_IDS[0] - expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(`aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`) - expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe( - `aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}` + expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe( + `aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}` + ) + expect( + buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay }) + ).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`) + expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe( + `aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH` ) - expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH`) // Anything that is not exactly a uuid is dropped, never echoed into the QR. - for (const bad of [`https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, relay.toUpperCase(), `${relay}&u=1.2.3.4:1`, '', 7 as any]) { - expect(buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).not.toContain('r=') - expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).toThrow('aiturk-mobile:no_lan_address') + for (const bad of [ + `https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, + relay.toUpperCase(), + `${relay}&u=1.2.3.4:1`, + '', + 7 as any + ]) { + expect( + buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad }) + ).not.toContain('r=') + expect(() => + buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad }) + ).toThrow('aiturk-mobile:no_lan_address') } const bodies: any[] = [] @@ -1777,13 +2037,22 @@ describe('pure helpers', () => { const fetcher = async (_url: string, init: any) => { bodies.push(JSON.parse(init.body)) - return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) } + return { + status: 201, + json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) + } } - expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay })).not.toBeNull() - expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })).not.toBeNull() + expect( + await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay }) + ).not.toBeNull() + expect( + await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp }) + ).not.toBeNull() // Neither a LAN url nor a relay: nothing is published. - expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' })).toBeNull() + expect( + await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' }) + ).toBeNull() expect(bodies).toEqual([ { lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay }, { lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp } @@ -1793,7 +2062,10 @@ describe('pure helpers', () => { it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => { const query = firewallQueryCommand('C:\\Windows') - expect(query).toEqual({ file: 'C:\\Windows\\System32\\netsh.exe', args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] }) + expect(query).toEqual({ + file: 'C:\\Windows\\System32\\netsh.exe', + args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] + }) expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe') const english = (port: string, enabled = 'Yes') => @@ -1840,14 +2112,24 @@ describe('pure helpers', () => { expect(parseFirewallRuleOutput(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] }) expect(parseFirewallRuleOutput(turkish('47321'), 0)).toEqual({ state: 'present', ports: [47321] }) expect(parseFirewallRuleOutput(english('47330'), 0)).toEqual({ state: 'present', ports: [47330] }) - expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ state: 'present', ports: [47321, 47325, 47326] }) + expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ + state: 'present', + ports: [47321, 47325, 47326] + }) // Two rules under the same name (the port changed once): both ports count. - expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ state: 'present', ports: [47321, 47322] }) + expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ + state: 'present', + ports: [47321, 47322] + }) // A disabled rule allows nothing. expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] }) expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 0)).toEqual({ state: 'present', ports: [] }) - expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ state: 'absent' }) - expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' }) + expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ + state: 'absent' + }) + expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ + state: 'absent' + }) // No answer at all (netsh missing, timeout) is not "no rule". expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' }) expect(parseFirewallRuleOutput('', 1)).toEqual({ state: 'unknown' }) @@ -1871,9 +2153,16 @@ describe('pure helpers', () => { expect(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] }) expect(ran).toEqual([query]) - const failing = createWindowsFirewall({ platform: 'win32', env: {}, spawn: () => ({ on: () => undefined }), run: async () => Promise.reject(new Error('x')) }) + const failing = createWindowsFirewall({ + platform: 'win32', + env: {}, + spawn: () => ({ on: () => undefined }), + run: async () => Promise.reject(new Error('x')) + }) expect(await failing.queryRule!()).toEqual({ state: 'unknown' }) - expect(await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!()).toEqual({ state: 'unknown' }) + expect( + await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!() + ).toEqual({ state: 'unknown' }) }) }) diff --git a/apps/desktop/electron/mobile-lan-proxy.ts b/apps/desktop/electron/mobile-lan-proxy.ts index cde36e6..e10fc44 100644 --- a/apps/desktop/electron/mobile-lan-proxy.ts +++ b/apps/desktop/electron/mobile-lan-proxy.ts @@ -150,7 +150,11 @@ export function selectLanAddresses( const ipv4 = item.family === 'IPv4' || item.family === 4 if (ipv4 && !item.internal && isPrivateIpv4(item.address) && !rows.some(row => row.address === item.address)) { - rows.push({ address: item.address, primary: item.address === defaultRoute, virtual: VIRTUAL_ADAPTER_RE.test(name) }) + rows.push({ + address: item.address, + primary: item.address === defaultRoute, + virtual: VIRTUAL_ADAPTER_RE.test(name) + }) } } } @@ -216,7 +220,13 @@ export function defaultRouteAddress(): Promise { * registration exists. A QR with neither would be a code that cannot work, so * that is refused. */ -export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string; relay?: null | string }): string { +export function buildPairQrPayload(input: { + addresses: string[] + port: number + fingerprint: string + code: string + relay?: null | string +}): string { const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) const relay = isRelayId(input.relay) ? input.relay : null @@ -437,7 +447,10 @@ export interface MobileFirewall { /** `netsh advfirewall firewall show rule name=…` — fixed arguments, no shell, no elevation. */ export function firewallQueryCommand(systemRoot?: string) { - const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows' + const root = + systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) + ? systemRoot.replace(/\\+$/, '') + : 'C:\\Windows' return { file: `${root}\\System32\\netsh.exe`, @@ -479,7 +492,9 @@ export function parseFirewallRuleOutput(stdout: string, exitCode: null | number) continue } - const portLine = block.find(line => /^LocalPort\s*:/i.test(line)) ?? block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line))) + const portLine = + block.find(line => /^LocalPort\s*:/i.test(line)) ?? + block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line))) for (const part of value(portLine).split(',')) { const [from, to = from] = part.split('-').map(item => Number(item.trim())) @@ -519,7 +534,10 @@ function assertPort(port: number): number { * shell is involved (the caller spawns PowerShell with an argument array). */ export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) { - const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows' + const root = + systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) + ? systemRoot.replace(/\\+$/, '') + : 'C:\\Windows' const netshArgs = action === 'add' @@ -537,7 +555,11 @@ export function firewallCommand(action: 'add' | 'remove', port: number, systemRo } export interface FirewallSpawn { - (file: string, args: string[], options: Record): { + ( + file: string, + args: string[], + options: Record + ): { on: (event: string, listener: (...args: any[]) => void) => unknown } } @@ -686,7 +708,9 @@ export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled' export interface LanProxyOptions { tls: { key: string; cert: string } /** Must resolve the LOCAL backend or throw (remote mode, backend down). */ - resolveBackend: () => Promise + resolveBackend: (profile: string) => Promise + /** Resolution only, never the duration of an agent/tool/cron execution. */ + backendResolveTimeoutMs?: number version: string pcName: string devices?: MobileDeviceRecord[] @@ -733,7 +757,13 @@ const DROPPED_REQUEST_HEADERS = new Set([ const DROPPED_RESPONSE_HEADERS = ['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'proxy-authenticate'] const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']) -const STATUS_TEXT: Record = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' } +const STATUS_TEXT: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 503: 'Service Unavailable' +} export function createLanProxy(options: LanProxyOptions) { const now = options.now ?? Date.now @@ -752,7 +782,13 @@ export function createLanProxy(options: LanProxyOptions) { } const records = (): MobileDeviceRecord[] => - [...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt })) + [...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ + id, + name, + tokenHash, + createdAt, + lastSeenAt + })) const log = (method: string | undefined, path: string, status: number) => { if (options.log) { @@ -814,9 +850,21 @@ export function createLanProxy(options: LanProxyOptions) { return device } - async function localBackend(): Promise { + async function localBackend(url: URL): Promise { + let timer: ReturnType | undefined try { - const backend = await options.resolveBackend() + // A paired phone controls this machine, but its selected profile is NOT + // the desktop window's selection. `all` is an aggregate read on the root + // dashboard, not a profile to spawn. Keep the original query downstream. + const selected = url.searchParams.get('profile')?.trim().toLowerCase() || 'default' + const profile = selected === 'all' ? 'default' : selected + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) return null + const backend = await Promise.race([ + options.resolveBackend(profile), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('backend_not_ready')), options.backendResolveTimeoutMs ?? 8000) + }) + ]) const target = new URL(backend.baseUrl) // Never forward a phone to anything but the loopback backend. @@ -827,6 +875,8 @@ export function createLanProxy(options: LanProxyOptions) { return { target, token: backend.token } } catch { return null + } finally { + if (timer) clearTimeout(timer) } } @@ -883,7 +933,13 @@ export function createLanProxy(options: LanProxyOptions) { } // eslint-disable-next-line no-control-regex - const name = typeof data?.device_name === 'string' ? data.device_name.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() : '' + const name = + typeof data?.device_name === 'string' + ? data.device_name + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + : '' if (!data || typeof data !== 'object' || typeof data.code !== 'string' || name.length < 1 || name.length > 60) { return reply(req, res, path, 400, { error: 'invalid_request' }) @@ -933,12 +989,27 @@ export function createLanProxy(options: LanProxyOptions) { return reply(req, res, path, 500, { error: 'internal' }) } - options.onPairingEnded?.('claimed', records().find(row => row.id === device.id)) + options.onPairingEnded?.( + 'claimed', + records().find(row => row.id === device.id) + ) - return reply(req, res, path, 200, { token, device_id: device.id, pc_name: options.pcName, version: options.version }) + return reply(req, res, path, 200, { + token, + device_id: device.id, + pc_name: options.pcName, + version: options.version + }) } - function forward(req: http.IncomingMessage, res: http.ServerResponse, url: URL, path: string, device: LiveDevice, backend: { target: URL; token: string }) { + function forward( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + path: string, + device: LiveDevice, + backend: { target: URL; token: string } + ) { const headers: Record = {} for (const [key, value] of Object.entries(req.headers)) { @@ -1037,7 +1108,9 @@ export function createLanProxy(options: LanProxyOptions) { return reply(req, res, path, 403, { error: 'forbidden' }) } - const backend = await localBackend() + const backend = await localBackend(url) + + if (res.destroyed) return if (!backend) { return reply(req, res, path, 503, { error: 'backend_unavailable' }) @@ -1086,7 +1159,9 @@ export function createLanProxy(options: LanProxyOptions) { return refuse(path, 403, 'forbidden') } - const backend = await localBackend() + const backend = await localBackend(url) + + if (socket.destroyed) return if (!backend) { return refuse(path, 503, 'backend_unavailable') @@ -1118,9 +1193,19 @@ export function createLanProxy(options: LanProxyOptions) { socket.on('close', close) upstream.once('connect', () => { // No Origin / Cookie / Authorization: only what the WebSocket handshake needs. - const lines = [`GET ${path}${url.search} HTTP/1.1`, `Host: ${backend.target.host}`, 'Connection: Upgrade', 'Upgrade: websocket'] + const lines = [ + `GET ${path}${url.search} HTTP/1.1`, + `Host: ${backend.target.host}`, + 'Connection: Upgrade', + 'Upgrade: websocket' + ] - for (const key of ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol']) { + for (const key of [ + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions', + 'sec-websocket-protocol' + ]) { const value = req.headers[key] if (typeof value === 'string' && value) { @@ -1140,9 +1225,12 @@ export function createLanProxy(options: LanProxyOptions) { }) } - const server = https.createServer({ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' }, (req, res) => { - onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' })) - }) + const server = https.createServer( + { key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' }, + (req, res) => { + onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' })) + } + ) server.on('upgrade', (req, socket, head) => { onUpgrade(req, socket, head).catch(() => socket.destroy()) @@ -1201,7 +1289,11 @@ export function createLanProxy(options: LanProxyOptions) { beginPairing(): { code: string; expiresAt: number } { const code = generatePairCode(randomBytes) - pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 } + pairing = { + codeHash: crypto.createHash('sha256').update(code).digest(), + expiresAt: now() + codeTtlMs, + attempts: 0 + } return { code, expiresAt: pairing.expiresAt } }, @@ -1217,7 +1309,13 @@ export function createLanProxy(options: LanProxyOptions) { pairingOpen, records, devices: (): MobileDeviceView[] => - [...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })), + [...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ + id, + name, + createdAt, + lastSeenAt, + live: live.size + })), /** Removes the device: its token turns 401 and its open connections are closed. */ async revoke(id: string): Promise { for (const [hash, device] of devices) { @@ -1293,7 +1391,8 @@ export function createMobileLinkFileStore(directory: string, file: string, fs: M } } -const validPort = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536 +const validPort = (value: unknown): value is number => + Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536 export function parseMobileLinkStored(raw: unknown): MobileLinkStored { const row = (raw && typeof raw === 'object' ? raw : {}) as Record @@ -1407,12 +1506,13 @@ export interface MobileLinkState { devices: MobileDeviceView[] } -export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } +export type MobileLinkEvent = + { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } export interface MobileLinkDeps { vault: MobileLanVault store: MobileLinkStore - resolveBackend: () => Promise + resolveBackend: (profile: string) => Promise isRemoteMode: () => boolean firewall: MobileFirewall fetch: PairFetch @@ -1577,7 +1677,12 @@ export function createMobileLink(deps: MobileLinkDeps) { port: data.port, remote: deps.isRemoteMode(), firewall: firewallState(data), - relay: { status: relayState.status, code: relayState.code, ready: data.relayId !== null, reissued: data.relayReissued }, + relay: { + status: relayState.status, + code: relayState.code, + ready: data.relayId !== null, + reissued: data.relayReissued + }, lan: { direct: data.lanDirect, listening: listening && data.lanDirect && proxy?.host === lanHost, @@ -1586,7 +1691,13 @@ export function createMobileLink(deps: MobileLinkDeps) { }, pcName, pairing: session - ? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath, securityCode: session.fingerprint.slice(0, 8) } + ? { + code: session.code, + qr: session.qr, + expiresAt: session.expiresAt, + codePath: session.codePath, + securityCode: session.fingerprint.slice(0, 8) + } : null, devices: proxy ? proxy.devices() @@ -1635,7 +1746,13 @@ export function createMobileLink(deps: MobileLinkDeps) { function publishOffer(current: PairSession, relayId: null | string) { current.codePath = 'pending' - const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId } + const input = { + code: current.code, + addresses: current.addresses, + port: current.port, + fingerprint: current.fingerprint, + relay: relayId + } void publishPairOffer(deps.fetch, input, { sleep, @@ -1704,12 +1821,12 @@ export function createMobileLink(deps: MobileLinkDeps) { proxy = createLanProxy({ tls: identity, - resolveBackend: async () => { + resolveBackend: async profile => { if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } - return deps.resolveBackend() + return deps.resolveBackend(profile) }, version: deps.version, pcName, @@ -1785,12 +1902,17 @@ export function createMobileLink(deps: MobileLinkDeps) { const row = (await deps.vault.load())?.relay - return isRelayCredentials(row) && row.relayId === data.relayId ? { relayId: row.relayId, hostToken: row.hostToken } : null + return isRelayCredentials(row) && row.relayId === data.relayId + ? { relayId: row.relayId, hostToken: row.hostToken } + : null }, async save(credentials, info) { const previous = data.relayId - await deps.vault.save({ ...((await deps.vault.load()) ?? {}), relay: { relayId: credentials.relayId, hostToken: credentials.hostToken } }) + await deps.vault.save({ + ...((await deps.vault.load()) ?? {}), + relay: { relayId: credentials.relayId, hostToken: credentials.hostToken } + }) data.relayId = credentials.relayId // The public address the paired phones stored is gone with the old registration. @@ -1876,7 +1998,8 @@ export function createMobileLink(deps: MobileLinkDeps) { * is paired OR pairing is in progress). Both follow the same condition. */ async function syncListening(data: MobileLinkStored) { - const wanted = () => !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null) + const wanted = () => + !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null) // ensureProxy may drop phones paired against a replaced certificate, so ask again after it. const instance = wanted() ? await ensureProxy(data) : proxy @@ -2002,7 +2125,9 @@ export function createMobileLink(deps: MobileLinkDeps) { } // LAN candidates only when the phone could really reach them. - const addresses = directWanted(data) ? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) : [] + const addresses = directWanted(data) + ? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) + : [] if (!relayUrl && !addresses.length) { throw new MobileLinkError('no_lan_address') @@ -2022,9 +2147,12 @@ export function createMobileLink(deps: MobileLinkDeps) { codePath: 'pending', offer: null, // The proxy notices expiry lazily (on a request); this closes the socket on time. - timer: setTimer(() => { - instance.pairingOpen() - }, Math.max(0, expiresAt - now()) + 50), + timer: setTimer( + () => { + instance.pairingOpen() + }, + Math.max(0, expiresAt - now()) + 50 + ), addresses, port, fingerprint, diff --git a/apps/desktop/electron/mobile-live.test.ts b/apps/desktop/electron/mobile-live.test.ts new file mode 100644 index 0000000..ced1957 --- /dev/null +++ b/apps/desktop/electron/mobile-live.test.ts @@ -0,0 +1,139 @@ +/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */ +import https from 'node:https' +import crypto from 'node:crypto' +import type { PeerCertificate } from 'node:tls' +import { expect, it } from 'vitest' +import { WebSocket } from 'ws' +import { createSelfSignedCertificate } from './mobile-lan-cert' +import { createLanProxy } from './mobile-lan-proxy' + +it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)( + 'real backend: pinned TLS, screens, cron lifecycle and chat socket', + async () => { + const port = Number(process.env.AITURK_TEST_BACKEND_PORT) + expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true) + const baseUrl = `http://127.0.0.1:${port}` + const root = await (await fetch(baseUrl)).text() + const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1] + expect(Boolean(session)).toBe(true) + const identity = createSelfSignedCertificate() + const checkServerIdentity = (_host: string, cert: PeerCertificate) => + crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint + ? undefined + : new Error('Certificate pin mismatch') + const proxy = createLanProxy({ + tls: identity, + version: 'acceptance', + pcName: 'acceptance', + resolveBackend: async profile => { + if (profile !== 'default') throw new Error('Acceptance may only use the default profile') + return { baseUrl, token: session! } + } + }) + const proxyPort = await proxy.listen(0, '127.0.0.1') + let deviceToken = '' + let createdId: string | undefined + const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> => + new Promise((resolve, reject) => { + const req = https.request( + { + hostname: '127.0.0.1', + port: proxyPort, + path, + method, + ca: identity.cert, + checkServerIdentity, + headers: { + ...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}), + 'Content-Type': 'application/json' + } + }, + res => { + let value = '' + res.on('data', chunk => (value += chunk)) + res.on('end', () => { + try { + resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null }) + } catch { + reject(new Error(`Non-JSON response: ${path}`)) + } + }) + } + ) + req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`))) + req.on('error', reject) + req.end(body === undefined ? undefined : JSON.stringify(body)) + }) + try { + const pair = await call('/aiturk/pair/claim', 'POST', { + code: proxy.beginPairing().code, + device_name: 'Acceptance ephemeral' + }) + expect(pair.status).toBe(200) + deviceToken = pair.data.token + for (const endpoint of [ + 'health', + 'profiles', + 'sessions', + 'cron/jobs', + 'cron/blueprints', + 'cron/delivery-targets', + 'model/info', + 'skills', + 'tools/toolsets', + 'mcp/servers', + 'plugins/kanban/boards' + ]) { + const response = await call(`/api/${endpoint}?profile=default`) + expect(response.status, endpoint).toBe(200) + } + const created = await call('/api/cron/jobs?profile=default', 'POST', { + name: `AITURK mobile acceptance ${Date.now()}`, + schedule: '0 0 1 1 *', + prompt: 'Temporary acceptance record; deleted immediately without execution.', + deliver: 'local' + }) + expect(created.status).toBe(200) + createdId = created.data.id + expect(typeof createdId).toBe('string') + const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}` + expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200) + expect( + (await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status + ).toBe(200) + expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200) + await new Promise((resolve, reject) => { + const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, { + ca: identity.cert, + checkServerIdentity, + headers: { Authorization: `Bearer ${deviceToken}` } + }) + const timer = setTimeout(() => { + ws.terminate() + reject(new Error('Chat ping timed out')) + }, 15000) + ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} }))) + ws.on('message', raw => { + const message = JSON.parse(String(raw)) + if (message.id === 123) { + clearTimeout(timer) + ws.close() + message.error ? reject(new Error('Chat ping rejected')) : resolve() + } + }) + ws.on('error', error => { + clearTimeout(timer) + reject(error) + }) + }) + } finally { + if (createdId) { + const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default` + expect((await call(route, 'DELETE')).status).toBe(200) + expect((await call(route)).status).toBe(404) + } + await proxy.stop() + } + }, + 180000 +) diff --git a/apps/desktop/electron/primary-backend-startup.test.ts b/apps/desktop/electron/primary-backend-startup.test.ts index 72cb072..a4bcd09 100644 --- a/apps/desktop/electron/primary-backend-startup.test.ts +++ b/apps/desktop/electron/primary-backend-startup.test.ts @@ -173,3 +173,21 @@ test('reset rejects with a typed error and never enters either backend', async ( assert.equal(options.connectRemote.mock.calls.length, 0) assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) }) + +test('a sibling runtime completion re-resolves the primary profile without re-running the stale installer', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const installed = { kind: 'python', activeRoot: bootstrapBackend.activeRoot, args: ['--profile', 'fusion-frontier'] } + const prepareLocalBackend = vi.fn().mockResolvedValueOnce(bootstrapBackend).mockResolvedValue(installed) + const options = startupOptions({ waitForDecision: gate.wait, prepareLocalBackend }) + const pending = runPrimaryBackendStartup(options) + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.runtimeAvailable(bootstrapBackend.activeRoot) + await pending + assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[installed]]) +}) + +test('a runtime that disappeared after readiness cannot silently trigger another installation', async () => { + const options = startupOptions({ waitForDecision: async () => 'runtime-ready' as const }) + await assert.rejects(runPrimaryBackendStartup(options), /no longer available/) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) diff --git a/apps/desktop/electron/primary-backend-startup.ts b/apps/desktop/electron/primary-backend-startup.ts index c105b7f..91b684e 100644 --- a/apps/desktop/electron/primary-backend-startup.ts +++ b/apps/desktop/electron/primary-backend-startup.ts @@ -109,5 +109,13 @@ export async function runPrimaryBackendStartup