fix(mobile): route phone APIs to selected profile and unblock runtime readiness

This commit is contained in:
yilsem committed 2026-10-07 20:10:36 +03:00
1 parent b1db2870c1
commit 61d765e2df
15 files changed
+970 -176

No files matched your search

+19
View File
@@ -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
@@ -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.
@@ -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)
})
+30 -2
View File
@@ -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<FirstRunSetupDecision>
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
}
+42 -21
View File
@@ -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
@@ -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<typeof local>(() => {})
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')
})
+18
View File
@@ -0,0 +1,18 @@
/** Mobile routing is explicit and cannot start a first-run installer. */
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
isRemote,
runtimeNeedsInstall,
ensureBackend
}: {
isRemote: (profile: string) => boolean
runtimeNeedsInstall: (profile: string) => boolean
ensureBackend: (profile: string) => Promise<Connection>
}) {
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 }
}
}
+401 -112
View File
@@ -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<string, unknown> = {}) {
@@ -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 <b>9</b> ' } })
const ok = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: typed, device_name: ' Pixel\u0000 <b>9</b> ' }
})
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<string>((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<string>(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<string>(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<string, any> = {}) {
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<string, any> = {}) {
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' })
})
})
+167 -39
View File
@@ -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<null | string> {
* 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<string, unknown>): {
(
file: string,
args: string[],
options: Record<string, unknown>
): {
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<MobileBackend>
resolveBackend: (profile: string) => Promise<MobileBackend>
/** 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<number, string> = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' }
const STATUS_TEXT: Record<number, string> = {
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<null | { target: URL; token: string }> {
async function localBackend(url: URL): Promise<null | { target: URL; token: string }> {
let timer: ReturnType<typeof setTimeout> | 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<never>((_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<string, string | string[]> = {}
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<boolean> {
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<string, unknown>
@@ -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<MobileBackend>
resolveBackend: (profile: string) => Promise<MobileBackend>
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,
+139
View File
@@ -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<void>((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
)
@@ -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)
})
@@ -109,5 +109,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
throw new FirstRunSetupResetError()
}
if (decision === 'runtime-ready') {
const current = await prepareLocalBackend()
if ((current as { kind?: string }).kind === 'bootstrap-needed') {
throw new Error('The completed local runtime is no longer available. Retry setup.')
}
return { kind: 'local', backend: await ensureLocalRuntime(current) }
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.18",
"version": "1.0.0-beta.19",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: [
'electron/mobile-*.test.ts',
'electron/first-run-setup*.test.ts',
'electron/primary-backend-startup.test.ts'
]
}
})
+1 -1
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.18",
"version": "1.0.0-beta.19",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",