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