Compare commits

...
Author SHA1 Message Date
yilsem 61d765e2df fix(mobile): route phone APIs to selected profile and unblock runtime readiness 2026-10-07 20:10:36 +03:00
yilsemandClaude Opus 5.5 b1db2870c1 ci(gitea): run the approval-gate tests on the Windows runner
This fork had no CI on Gitea (Actions was off; the upstream .github
workflows do not run here). Add one job that runs the protected
instruction-file gate tests and the file-write safety tests on the
Windows runner. Seven upstream tests that cannot pass on Windows (POSIX
paths, symlink privilege, POSIX stat) are deselected by name and listed
in the job log.

Kart: turkservis-20261007-1421-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
2026-10-07 18:12:14 +03:00
15 changed files with 1058 additions and 176 deletions

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
# AITURK IDE ajan CI (kart turkservis-20261007-1421-2).
#
# Bu depo bir yukari akis catalidir; .github/workflows altindaki GitHub is akislari Gitea'da kosmaz. .gitea/workflows
# klasoru var oldugu surece Gitea yalniz buradaki is akislarini okur.
#
# Ne kosar: korumali talimat dosyasi onay kapisinin testleri (insana sorulamayan kosuda aninda BLOCKED, gec onayin
# uygulanmamasi, yolo ile kapinin acilmamasi) ve dosya yazma guvenligi testleri. Kosucu Windows'tur (etiket
# windows-latest; 7 Ekim 2026'da cevrimici kosucu hermes-laptop-windows): urunun hedefi Windows x64'tur ve hata
# Windows asama kosusunda gorulmustur.
#
# Ne sayilmaz (durustluk notu): tests/tools/test_file_write_safety.py icindeki 7 test Windows'ta bu degisiklikten
# bagimsiz olarak gecemez; yukari akis bunlari POSIX icin yazmistir (/etc, /private/etc, /boot yollari; yonetici
# ayricaligi isteyen symlink; POSIX stat alanlari). Asagida adlariyla deselect edilir; degismemis 58dbb4523b
# commit'inde de ayni 7 test kaliyordu (7 Ekim 2026 olcumu). Tam yukari akis test takimi burada KOSMAZ.
name: ci
on:
push:
branches:
- aiturk/main
- fix/ide-windows-vcruntime
workflow_dispatch:
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
steps:
- name: Kaynagi al
uses: actions/checkout@v4
with:
github-server-url: https://gitea.twinpay.one
- name: Ortami kur
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
"kosucu: $env:COMPUTERNAME / $env:USERNAME"
"checkout SHA: $(git rev-parse HEAD) / beklenen: $env:GITHUB_SHA"
$py = $null
$launcher = Get-Command py -ErrorAction SilentlyContinue
if ($launcher) { $py = (& $launcher.Source -3 -c 'import sys; print(sys.executable)').Trim() }
if (-not $py) {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source -notlike '*\WindowsApps\*') { $py = $cmd.Source }
}
if (-not $py) { throw 'Python bulunamadi' }
"onyukleme python: $py"
& $py -m pip install --quiet --disable-pip-version-check --target "$env:RUNNER_TEMP\uvboot" uv
if ($LASTEXITCODE -ne 0) { throw 'uv kurulamadi' }
$env:PYTHONPATH = "$env:RUNNER_TEMP\uvboot"
& $py -m uv venv --python 3.11 "$env:RUNNER_TEMP\ci-venv"
if ($LASTEXITCODE -ne 0) { throw 'venv olusturulamadi' }
& $py -m uv pip install --python "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" -e . pytest
if ($LASTEXITCODE -ne 0) { throw 'bagimliliklar kurulamadi' }
$env:PYTHONPATH = ''
& "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" --version
- name: Onay kapisi testleri
shell: powershell
run: |
$ErrorActionPreference = 'Continue'
$env:PYTHONDONTWRITEBYTECODE = '1'
$env:PYTHONUTF8 = '1'
$venv = "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe"
$f = 'tests/tools/test_file_write_safety.py'
$sayilmayan = @(
"${f}::TestCheckSensitivePathMacOSBypass::test_etc_hosts_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_hosts_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_ssh_config_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_var_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_boot_still_blocked",
"${f}::TestAtomicWrite::test_patch_routes_through_atomic_write",
"${f}::TestProtectedInstructionFiles::test_symlink_to_protected_file_is_gated"
)
$deselect = @()
foreach ($t in $sayilmayan) { $deselect += '--deselect'; $deselect += $t }
"CI OZETI: Windows'ta sayilmayan (deselect) test sayisi: $($sayilmayan.Count)"
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
tests/cli/test_single_query_session_finalize.py @deselect -q -p no:cacheprovider -rf
$kod = $LASTEXITCODE
"pytest cikis kodu: $kod"
exit $kod
@@ -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(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true) 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 { interface FirstRunSetupBackend {
activeRoot?: string activeRoot?: string
kind?: string kind?: string
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
} }
interface FirstRunSetupGateOptions { interface FirstRunSetupGateOptions {
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
hideChoice?: () => void hideChoice?: () => void
log?: (message: string) => void log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
stuckAfterMs?: number stuckAfterMs?: number
} }
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
export function createFirstRunSetupGate({ export function createFirstRunSetupGate({
isRuntimeAvailable,
hideChoice, hideChoice,
log, log,
onStuck, onStuck,
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
let localBootstrapConfirmed = false let localBootstrapConfirmed = false
let waiter: { let waiter: {
activeRoot?: string
promise: Promise<FirstRunSetupDecision> promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void resolve: (decision: FirstRunSetupDecision) => void
} | null = null } | null = null
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
return 'continue-local' as const 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) { if (waiter) {
return waiter.promise return waiter.promise
} }
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
resolveWaiter = resolve resolveWaiter = resolve
}) })
waiter = { promise, resolve: resolveWaiter } waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
return promise return promise
} }
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
settleWaiter('continue-local') 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 = () => { const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call. // Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local // Settle the old boot explicitly so it cannot fall through into local
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
isLocalBootstrapConfirmed, isLocalBootstrapConfirmed,
resetForRepair, resetForRepair,
resetForRetry, resetForRetry,
runtimeAvailable,
shouldGate, shouldGate,
wait wait
} }
+42 -21
View File
@@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws'
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
import { createMobileBackendResolver } from './mobile-backend'
import { import {
createMobileLink, createMobileLink,
createMobileLinkFileStore, createMobileLinkFileStore,
@@ -2075,6 +2076,10 @@ function hideFirstRunSetupChoice() {
function getFirstRunSetupGate() { function getFirstRunSetupGate() {
if (!firstRunSetupGate) { if (!firstRunSetupGate) {
firstRunSetupGate = createFirstRunSetupGate({ 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, hideChoice: hideFirstRunSetupChoice,
log: rememberLog, log: rememberLog,
onStuck: (_backend, stuckAfterMs) => { onStuck: (_backend, stuckAfterMs) => {
@@ -5259,6 +5264,11 @@ async function ensureRuntime(backend) {
error: null 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 return backend
} }
@@ -6795,7 +6805,7 @@ function buildApplicationMenu() {
const checkForUpdatesItem = { const checkForUpdatesItem = {
label: 'Check for Updates…', 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) { if (IS_MAC) {
@@ -17646,8 +17656,11 @@ async function getUninstallSummary() {
async function runDesktopUninstall(_mode) { async function runDesktopUninstall(_mode) {
// The upstream cleanup script removes Hermes-specific shortcuts, registry // The upstream cleanup script removes Hermes-specific shortcuts, registry
// entries and global launchers. AITURK is owned by its OS package manager. // entries and global launchers. AITURK is owned by its OS package manager.
return { ok: false, error: 'aiturk-os-uninstaller', return {
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' } 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()) 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. // Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
vault: new MobileVault(directory, safeStorage), vault: new MobileVault(directory, safeStorage),
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises), store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
resolveBackend: async () => { resolveBackend: createMobileBackendResolver({
if (mobileLinkRemoteMode()) { isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
throw new Error('aiturk-mobile:remote_mode') runtimeNeedsInstall: profile =>
} resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
'bootstrap-needed',
const connection = await ensureBackend(primaryProfileKey()) ensureBackend
}),
if (connection.mode !== 'local') {
throw new Error('aiturk-mobile:remote_mode')
}
return { baseUrl: connection.baseUrl, token: connection.token }
},
isRemoteMode: mobileLinkRemoteMode, isRemoteMode: mobileLinkRemoteMode,
firewall: createWindowsFirewall({ firewall: createWindowsFirewall({
spawn, spawn,
@@ -17875,11 +17882,20 @@ function getMobileLink(): MobileLink {
// console code page; latin1 keeps every byte and the parser needs no translated word. // console code page; latin1 keeps every byte and the parser needs no translated word.
run: (file, args) => run: (file, args) =>
new Promise(resolve => { new Promise(resolve => {
execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => { execFile(
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0 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), fetch: (url, init) => fetch(url, init as RequestInit),
@@ -17939,7 +17955,12 @@ app.whenReady().then(() => {
} }
return ensureBackend(profile) 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.')) .catch(() => rememberLog('[chrome] Native bridge could not start.'))
} }
// Warm the login-shell PATH resolution immediately so it usually completes // 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 SESSION = crypto.randomBytes(32).toString('base64url')
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']) 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 seen: { path: string; host: string; raw: string; method: string }[] = []
const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ') 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 })) 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 = {}) { function call(port: number, method: string, path: string, options: CallOptions = {}) {
return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>((resolve, reject) => { return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>(
const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) (resolve, reject) => {
const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body))
const req = https.request( const req = https.request(
{ {
host: '127.0.0.1', host: '127.0.0.1',
port, port,
method, method,
path, path,
agent: false, agent: false,
...(options.ca ? { ca: options.ca } : {}), ...(options.ca ? { ca: options.ca } : {}),
checkServerIdentity: () => undefined, checkServerIdentity: () => undefined,
headers: { headers: {
...(options.token ? { authorization: `Bearer ${options.token}` } : {}), ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}),
...(payload ? { 'content-type': 'application/json' } : {}), ...(payload ? { 'content-type': 'application/json' } : {}),
origin: 'http://evil.example', origin: 'http://evil.example',
cookie: 'session=evil', cookie: 'session=evil',
...options.headers ...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
} }
},
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) try {
req.end(payload) 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> = {}) { 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 () => { it('claim is 410 pairing_closed while pairing is not open', async () => {
const t = await startProxy() 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' }]) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }])
await t.proxy.stop() await t.proxy.stop()
@@ -288,13 +301,19 @@ describe('LAN proxy: pairing (hello / claim)', () => {
const { code } = t.proxy.beginPairing() const { code } = t.proxy.beginPairing()
for (let i = 0; i < 4; i++) { 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' }]) expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }])
} }
const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} ` 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(ok.status).toBe(200)
expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version']) 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() const { code } = t.proxy.beginPairing()
for (let i = 0; i < 4; i++) { 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' }]) 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 t = await startProxy()
const { code } = t.proxy.beginPairing() 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 }) const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody })
expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }]) 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(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() await t.proxy.stop()
}) })
}) })
@@ -421,7 +462,11 @@ describe('LAN proxy: REST', () => {
for (const options of [{ token }, { headerToken: token }]) { for (const options of [{ token }, { headerToken: token }]) {
seen.length = 0 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.status).toBe(200)
expect(r.json).toEqual({ ok: true, path: '/api/sessions' }) expect(r.json).toEqual({ ok: true, path: '/api/sessions' })
@@ -498,7 +543,11 @@ describe('LAN proxy: REST', () => {
resolveCalls = 0 resolveCalls = 0
for (const [method, path] of forbidden) { 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' }]) 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() 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 () => { it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => {
const remote = await startProxy({ const remote = await startProxy({
resolveBackend: async () => { 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) expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503)
await remote.proxy.stop() 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() const b = await elsewhere.pair()
expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503) 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 t = await startProxy()
const peer = await new Promise<string>((resolve, reject) => { 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 => { const req = https.request(
resolve(crypto.createHash('sha256').update((res.socket as any).getPeerCertificate(true).raw).digest('base64url')) { host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false },
res.resume() res => {
}) resolve(
crypto
.createHash('sha256')
.update((res.socket as any).getPeerCertificate(true).raw)
.digest('base64url')
)
res.resume()
}
)
req.on('error', reject) req.on('error', reject)
req.end() req.end()
@@ -641,10 +744,14 @@ describe('LAN proxy: TLS, listening and logs', () => {
expect(peer).not.toBe(createSelfSignedCertificate().fingerprint) expect(peer).not.toBe(createSelfSignedCertificate().fingerprint)
// A phone pinned to ANOTHER certificate must refuse this server. // 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 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.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code)))
req.end() req.end()
@@ -653,7 +760,9 @@ describe('LAN proxy: TLS, listening and logs', () => {
expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/) expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/)
const plain = await new Promise<string>(resolve => { 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.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error')))
req.end() 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, // 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. // 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 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)}`) 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. */ /** 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) { if (isOffer) {
return { return {
status: offerOutcome, 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 firewallWrites = () => firewallCalls.filter(entry => entry !== 'query')
const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers')) 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', () => { describe('mobile link controller', () => {
it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => { it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => {
const h = linkHarness() 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 enabled = await h.link.enable()
const port = enabled.port as number const port = enabled.port as number
@@ -953,7 +1091,10 @@ describe('mobile link controller', () => {
expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code) expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code)
const ca = h.vault.value.lanTls.cert 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) expect(claim.status).toBe(200)
await until(() => h.events.some(event => event.type === 'paired')) await until(() => h.events.some(event => event.type === 'paired'))
@@ -1033,7 +1174,10 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin() const state = await h.link.pairBegin()
const port = state.port as number const port = state.port as number
const ca = h.vault.value.lanTls.cert 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')) 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 () => { 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() 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(h.firewallWrites()).toEqual([])
expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } }) expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } })
expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } }) 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. // The rule is there (asked from Windows, not remembered): no second prompt.
expect((await h.link.lanAllow()).firewall).toBe('ok') expect((await h.link.lanAllow()).firewall).toBe('ok')
expect(h.firewallWrites()).toHaveLength(3) 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') expect(h.firewallWrites().at(-1)).toBe('remove')
await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled') await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled')
await h.link.stop() await h.link.stop()
@@ -1113,7 +1269,10 @@ describe('mobile link controller', () => {
const port = first.port as number const port = first.port as number
// Listening, but only on loopback: the stand-in LAN address refuses the connection. // 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, '127.0.0.1')).toBe('open')
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED') expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
expect(first.pairing!.qr).not.toContain('u=') expect(first.pairing!.qr).not.toContain('u=')
@@ -1124,7 +1283,11 @@ describe('mobile link controller', () => {
const allowed = await h.link.lanAllow() const allowed = await h.link.lanAllow()
expect(h.firewallWrites()).toEqual([`add:${port}`]) 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) expect(h.store.value.lanDirect).toBe(true)
const second = await h.link.pairBegin() const second = await h.link.pairBegin()
@@ -1168,7 +1331,14 @@ describe('mobile link controller', () => {
const h = linkHarness({ const h = linkHarness({
rule: { state: 'present', ports: [47321] }, 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() const resumed = await h.link.start()
@@ -1189,7 +1359,11 @@ describe('mobile link controller', () => {
await h.link.enable() await h.link.enable()
await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/) 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([]) expect(h.offers()).toEqual([])
// Direct access on, but no usable LAN address either. // Direct access on, but no usable LAN address either.
@@ -1201,7 +1375,9 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin() 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() await h.link.stop()
const broken = createMobileLink({ 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.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] })
expect(h.vault.value.accountToken).toBe('eski-alan') 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) expect(claim.status).toBe(200)
await until(() => h.events.some(event => event.type === 'paired')) await until(() => h.events.some(event => event.type === 'paired'))
@@ -1263,10 +1442,16 @@ describe('mobile link controller', () => {
// "Röle kimliğini yenile". // "Röle kimliğini yenile".
const renewed = await h.link.relayRenew() 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.store.value.relayId).toBe(RELAY_IDS[1])
expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[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) await until(() => h.hostSockets[0].closed !== null)
const again = await h.link.pairBegin() const again = await h.link.pairBegin()
@@ -1275,7 +1460,9 @@ describe('mobile link controller', () => {
await until(() => h.events.at(-1)?.state.relay.status === 'connected') 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. // 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.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]}`) 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. // 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]) 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. // 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(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 '))).toEqual(
expect(h.logs.filter(line => line.startsWith('relay ')).every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true) 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() await h.link.stop()
}) })
@@ -1299,7 +1497,10 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin() 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 until(() => h.events.some(event => event.type === 'paired'))
await h.link.stop() await h.link.stop()
@@ -1453,7 +1654,13 @@ describe('mobile link controller: rendezvous record retries', () => {
const again = (await h.link.pairRepublish()).pairing! 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') await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready')
expect(h.offers()).toHaveLength(2) expect(h.offers()).toHaveLength(2)
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
@@ -1501,7 +1708,13 @@ describe('mobile link controller: rendezvous record retries', () => {
}) })
describe('publishPairOffer / cancelPairOffer', () => { 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)}` } const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }
function plan(outcomes: (number | 'throw')[]) { function plan(outcomes: (number | 'throw')[]) {
@@ -1577,7 +1790,10 @@ describe('publishPairOffer / cancelPairOffer', () => {
const told = plan([409]) 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 () => { 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(' abcd-efgh ')).toBe('ABCDEFGH')
expect(normalizePairCode(12345678)).toBe('') expect(normalizePairCode(12345678)).toBe('')
expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH') 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', () => { 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(['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 }] const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }]
@@ -1646,13 +1868,25 @@ describe('pure helpers', () => {
'Ethernet 4': v4('192.168.4.2') '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(interfaces, null)[0]).toBe('10.0.0.5')
expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([]) 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( expect(() =>
'aiturk-mobile:no_lan_address' 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: ['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` `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') 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 spawned: any[] = []
const firewall = createWindowsFirewall({ const firewall = createWindowsFirewall({
@@ -1725,7 +1963,11 @@ describe('pure helpers', () => {
parseMobileLinkStored({ parseMobileLinkStored({
enabled: 'yes', enabled: 'yes',
port: 99999, 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({ ).toEqual({
enabled: false, enabled: false,
@@ -1748,8 +1990,12 @@ describe('pure helpers', () => {
relayReissued: false relayReissued: false
}) })
// Only an explicit `true` turns direct LAN access on; a relay id must be a uuid. // 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(
expect(parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })).toMatchObject({ 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, lanDirect: true,
relayId: RELAY_IDS[0], relayId: RELAY_IDS[0],
relayReissued: true relayReissued: true
@@ -1760,16 +2006,30 @@ describe('pure helpers', () => {
const fp = 'f'.repeat(43) const fp = 'f'.repeat(43)
const relay = RELAY_IDS[0] 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: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe( `aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`
`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', 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. // 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]) { for (const bad of [
expect(buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).not.toContain('r=') `https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`,
expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).toThrow('aiturk-mobile:no_lan_address') 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[] = [] const bodies: any[] = []
@@ -1777,13 +2037,22 @@ describe('pure helpers', () => {
const fetcher = async (_url: string, init: any) => { const fetcher = async (_url: string, init: any) => {
bodies.push(JSON.parse(init.body)) 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(
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })).not.toBeNull() 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. // 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([ expect(bodies).toEqual([
{ lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay }, { lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay },
{ lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp } { 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 () => { it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => {
const query = firewallQueryCommand('C:\\Windows') 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') expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe')
const english = (port: string, enabled = 'Yes') => 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(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
expect(parseFirewallRuleOutput(turkish('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(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. // 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. // A disabled rule allows nothing.
expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] }) expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] })
expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 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\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({
expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' }) 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". // No answer at all (netsh missing, timeout) is not "no rule".
expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' }) expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' })
expect(parseFirewallRuleOutput('', 1)).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(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] })
expect(ran).toEqual([query]) 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 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 const ipv4 = item.family === 'IPv4' || item.family === 4
if (ipv4 && !item.internal && isPrivateIpv4(item.address) && !rows.some(row => row.address === item.address)) { 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 * registration exists. A QR with neither would be a code that cannot work, so
* that is refused. * 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 hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS)
const relay = isRelayId(input.relay) ? input.relay : null 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. */ /** `netsh advfirewall firewall show rule name=…` — fixed arguments, no shell, no elevation. */
export function firewallQueryCommand(systemRoot?: string) { 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 { return {
file: `${root}\\System32\\netsh.exe`, file: `${root}\\System32\\netsh.exe`,
@@ -479,7 +492,9 @@ export function parseFirewallRuleOutput(stdout: string, exitCode: null | number)
continue 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(',')) { for (const part of value(portLine).split(',')) {
const [from, to = from] = part.split('-').map(item => Number(item.trim())) 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). * shell is involved (the caller spawns PowerShell with an argument array).
*/ */
export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) { 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 = const netshArgs =
action === 'add' action === 'add'
@@ -537,7 +555,11 @@ export function firewallCommand(action: 'add' | 'remove', port: number, systemRo
} }
export interface FirewallSpawn { 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 on: (event: string, listener: (...args: any[]) => void) => unknown
} }
} }
@@ -686,7 +708,9 @@ export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
export interface LanProxyOptions { export interface LanProxyOptions {
tls: { key: string; cert: string } tls: { key: string; cert: string }
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */ /** 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 version: string
pcName: string pcName: string
devices?: MobileDeviceRecord[] 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 DROPPED_RESPONSE_HEADERS = ['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'proxy-authenticate']
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']) 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) { export function createLanProxy(options: LanProxyOptions) {
const now = options.now ?? Date.now const now = options.now ?? Date.now
@@ -752,7 +782,13 @@ export function createLanProxy(options: LanProxyOptions) {
} }
const records = (): MobileDeviceRecord[] => 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) => { const log = (method: string | undefined, path: string, status: number) => {
if (options.log) { if (options.log) {
@@ -814,9 +850,21 @@ export function createLanProxy(options: LanProxyOptions) {
return device 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 { 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) const target = new URL(backend.baseUrl)
// Never forward a phone to anything but the loopback backend. // Never forward a phone to anything but the loopback backend.
@@ -827,6 +875,8 @@ export function createLanProxy(options: LanProxyOptions) {
return { target, token: backend.token } return { target, token: backend.token }
} catch { } catch {
return null return null
} finally {
if (timer) clearTimeout(timer)
} }
} }
@@ -883,7 +933,13 @@ export function createLanProxy(options: LanProxyOptions) {
} }
// eslint-disable-next-line no-control-regex // 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) { if (!data || typeof data !== 'object' || typeof data.code !== 'string' || name.length < 1 || name.length > 60) {
return reply(req, res, path, 400, { error: 'invalid_request' }) 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' }) 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[]> = {} const headers: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(req.headers)) { 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' }) return reply(req, res, path, 403, { error: 'forbidden' })
} }
const backend = await localBackend() const backend = await localBackend(url)
if (res.destroyed) return
if (!backend) { if (!backend) {
return reply(req, res, path, 503, { error: 'backend_unavailable' }) return reply(req, res, path, 503, { error: 'backend_unavailable' })
@@ -1086,7 +1159,9 @@ export function createLanProxy(options: LanProxyOptions) {
return refuse(path, 403, 'forbidden') return refuse(path, 403, 'forbidden')
} }
const backend = await localBackend() const backend = await localBackend(url)
if (socket.destroyed) return
if (!backend) { if (!backend) {
return refuse(path, 503, 'backend_unavailable') return refuse(path, 503, 'backend_unavailable')
@@ -1118,9 +1193,19 @@ export function createLanProxy(options: LanProxyOptions) {
socket.on('close', close) socket.on('close', close)
upstream.once('connect', () => { upstream.once('connect', () => {
// No Origin / Cookie / Authorization: only what the WebSocket handshake needs. // 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] const value = req.headers[key]
if (typeof value === 'string' && value) { 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) => { const server = https.createServer(
onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' })) { 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) => { server.on('upgrade', (req, socket, head) => {
onUpgrade(req, socket, head).catch(() => socket.destroy()) onUpgrade(req, socket, head).catch(() => socket.destroy())
@@ -1201,7 +1289,11 @@ export function createLanProxy(options: LanProxyOptions) {
beginPairing(): { code: string; expiresAt: number } { beginPairing(): { code: string; expiresAt: number } {
const code = generatePairCode(randomBytes) 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 } return { code, expiresAt: pairing.expiresAt }
}, },
@@ -1217,7 +1309,13 @@ export function createLanProxy(options: LanProxyOptions) {
pairingOpen, pairingOpen,
records, records,
devices: (): MobileDeviceView[] => 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. */ /** Removes the device: its token turns 401 and its open connections are closed. */
async revoke(id: string): Promise<boolean> { async revoke(id: string): Promise<boolean> {
for (const [hash, device] of devices) { 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 { export function parseMobileLinkStored(raw: unknown): MobileLinkStored {
const row = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown> const row = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
@@ -1407,12 +1506,13 @@ export interface MobileLinkState {
devices: MobileDeviceView[] 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 { export interface MobileLinkDeps {
vault: MobileLanVault vault: MobileLanVault
store: MobileLinkStore store: MobileLinkStore
resolveBackend: () => Promise<MobileBackend> resolveBackend: (profile: string) => Promise<MobileBackend>
isRemoteMode: () => boolean isRemoteMode: () => boolean
firewall: MobileFirewall firewall: MobileFirewall
fetch: PairFetch fetch: PairFetch
@@ -1577,7 +1677,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
port: data.port, port: data.port,
remote: deps.isRemoteMode(), remote: deps.isRemoteMode(),
firewall: firewallState(data), 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: { lan: {
direct: data.lanDirect, direct: data.lanDirect,
listening: listening && data.lanDirect && proxy?.host === lanHost, listening: listening && data.lanDirect && proxy?.host === lanHost,
@@ -1586,7 +1691,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
}, },
pcName, pcName,
pairing: session 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, : null,
devices: proxy devices: proxy
? proxy.devices() ? proxy.devices()
@@ -1635,7 +1746,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
function publishOffer(current: PairSession, relayId: null | string) { function publishOffer(current: PairSession, relayId: null | string) {
current.codePath = 'pending' 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, { void publishPairOffer(deps.fetch, input, {
sleep, sleep,
@@ -1704,12 +1821,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
proxy = createLanProxy({ proxy = createLanProxy({
tls: identity, tls: identity,
resolveBackend: async () => { resolveBackend: async profile => {
if (deps.isRemoteMode()) { if (deps.isRemoteMode()) {
throw new MobileLinkError('remote_mode') throw new MobileLinkError('remote_mode')
} }
return deps.resolveBackend() return deps.resolveBackend(profile)
}, },
version: deps.version, version: deps.version,
pcName, pcName,
@@ -1785,12 +1902,17 @@ export function createMobileLink(deps: MobileLinkDeps) {
const row = (await deps.vault.load())?.relay 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) { async save(credentials, info) {
const previous = data.relayId 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 data.relayId = credentials.relayId
// The public address the paired phones stored is gone with the old registration. // 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. * is paired OR pairing is in progress). Both follow the same condition.
*/ */
async function syncListening(data: MobileLinkStored) { 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. // ensureProxy may drop phones paired against a replaced certificate, so ask again after it.
const instance = wanted() ? await ensureProxy(data) : proxy 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. // 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) { if (!relayUrl && !addresses.length) {
throw new MobileLinkError('no_lan_address') throw new MobileLinkError('no_lan_address')
@@ -2022,9 +2147,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
codePath: 'pending', codePath: 'pending',
offer: null, offer: null,
// The proxy notices expiry lazily (on a request); this closes the socket on time. // The proxy notices expiry lazily (on a request); this closes the socket on time.
timer: setTimer(() => { timer: setTimer(
instance.pairingOpen() () => {
}, Math.max(0, expiresAt - now()) + 50), instance.pairingOpen()
},
Math.max(0, expiresAt - now()) + 50
),
addresses, addresses,
port, port,
fingerprint, 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.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.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() 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) } return { kind: 'local', backend: await ensureLocalRuntime(backend) }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "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.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "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": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.18", "version": "1.0.0-beta.19",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",