fix(mobile): route phone APIs to selected profile and unblock runtime readiness
This commit is contained in:
1 parent
b1db2870c1
commit
61d765e2df
15 files changed
+970
-176
No files matched your search
@@ -25,6 +25,25 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mobile-link-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.twinpay.one
|
||||
- name: Mobil TLS, profil ve baslatma regresyonlari
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$deps = Join-Path $env:RUNNER_TEMP "mobile-tests-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $deps | Out-Null
|
||||
npm install --prefix $deps --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Test bagimliliklari kurulamadi' }
|
||||
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
|
||||
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
|
||||
exit $LASTEXITCODE
|
||||
|
||||
approval-gate-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Mobile connection repair — beta.19
|
||||
|
||||
The paired phone previously resolved the desktop's active profile. If that
|
||||
profile's startup promise was waiting for first-run setup, every phone API
|
||||
request waited too, even while the default backend was already serving.
|
||||
|
||||
REST and WebSocket requests now resolve the phone's explicit `profile` query
|
||||
(default when absent). Aggregate `profile=all` reaches the default process
|
||||
without rewriting the downstream query. Invalid profile identifiers, remote
|
||||
backends and a missing local runtime are refused; phone requests do not install
|
||||
software or silently fall back to a different profile. Resolution has an eight
|
||||
second deadline; this is not an agent, tool or scheduled-job execution deadline.
|
||||
|
||||
Shared-runtime completion wakes only a waiting setup for the same runtime root.
|
||||
Primary startup then resolves its own profile again. This does not grant
|
||||
installation consent or substitute a sibling profile's backend connection.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 94 mobile transport, certificate, relay, profile and startup regression tests.
|
||||
- Electron TypeScript type check.
|
||||
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
|
||||
backend behind the production TLS proxy, pinned certificate, screen reads,
|
||||
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
|
||||
job is deleted; existing jobs are untouched. This test does not trigger an
|
||||
agent job or claim physical-phone/cellular acceptance.
|
||||
- Public relay hello was separately exercised using Android's production
|
||||
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
|
||||
|
||||
Publication, installed beta.19 and physical-phone acceptance are separate gates.
|
||||
Existing phone security exclusions are unchanged. Unsupported methods and
|
||||
features are not claimed to be implemented by this connection repair.
|
||||
@@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () =>
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false)
|
||||
assert.equal(await settledState(pending), 'pending')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true)
|
||||
assert.equal(await pending, 'runtime-ready')
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(hidden, 1)
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
gate.resetForRetry()
|
||||
assert.equal(await pending, 'reset')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('runtime completed before wait does not leave a stale setup prompt pending', async () => {
|
||||
let prompts = 0
|
||||
const gate = createFirstRunSetupGate({
|
||||
isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot,
|
||||
promptChoice: () => prompts++,
|
||||
stuckAfterMs: 0
|
||||
})
|
||||
assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready')
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(prompts, 0)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
})
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user