Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1acb5dd90f | ||
|
|
61d765e2df | ||
|
|
b1db2870c1 | ||
|
|
7e4cc0915c | ||
|
|
58dbb4523b | ||
|
|
2ed9a6b2de | ||
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 |
No files matched your search
@@ -0,0 +1,110 @@
|
||||
# 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 `
|
||||
tests/hermes_cli/test_plugin_catalog.py tests/hermes_cli/test_aiturk_plugin_catalog.py `
|
||||
tests/hermes_cli/test_plugin_install_ref.py `
|
||||
tests/tui_gateway/test_mobile_subagents.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.
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
||||
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
||||
* no network, browser or real CLI is touched. The fixture "keys" below are
|
||||
* obviously fake placeholders in the right shape.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
createAiturkLogin,
|
||||
resolveAiturkCliPath
|
||||
} from './aiturk-login'
|
||||
|
||||
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
||||
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
||||
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
const startBody = (over: Record<string, unknown> = {}) => ({
|
||||
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
deviceCode: DEVICE_CODE,
|
||||
userCode: 'ABCD2345',
|
||||
expiresIn: 600,
|
||||
interval: 3,
|
||||
verificationUri: URI,
|
||||
...over
|
||||
})
|
||||
|
||||
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
start?: { status: number; body?: unknown }
|
||||
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||
startErrors?: Error[]
|
||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
save?: unknown
|
||||
openExternal?: () => boolean
|
||||
} = {}
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const startErrors = [...(options.startErrors ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
|
||||
const fetch = vi.fn(async (url: string, init: any) => {
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const failure = startErrors.shift()
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
if (url.endsWith('/poll')) {
|
||||
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
||||
|
||||
if (r instanceof Error) {
|
||||
throw r
|
||||
}
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
return { status: 200, json: async () => ({}) }
|
||||
})
|
||||
|
||||
const backendRequest = vi.fn(async (request: any) => {
|
||||
if (request.path.endsWith('/validate')) {
|
||||
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
||||
}
|
||||
|
||||
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
||||
})
|
||||
|
||||
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
||||
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
||||
)
|
||||
|
||||
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
||||
|
||||
const login = createAiturkLogin({
|
||||
fetch,
|
||||
openExternal,
|
||||
hostname: () => 'TEST-PC',
|
||||
backendRequest,
|
||||
runCli,
|
||||
now: () => clock.now,
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, ms, cleared: false }
|
||||
timers.push(timer)
|
||||
|
||||
return timer
|
||||
},
|
||||
clearTimer: handle => {
|
||||
;(handle as { cleared: boolean }).cleared = true
|
||||
},
|
||||
timeoutSignal: () => undefined
|
||||
})
|
||||
|
||||
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
||||
}
|
||||
|
||||
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
||||
|
||||
describe('aiturk login: start', () => {
|
||||
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
||||
const h = harness()
|
||||
const started = await h.login.start({ profile: 'work' })
|
||||
|
||||
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(leaks(started)).toBe(false)
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
|
||||
const { url, init } = h.calls[0]
|
||||
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.redirect).toBe('error')
|
||||
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
||||
expect(init.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
||||
'https://turkservis.online/account/aiturk/device-login/../../x',
|
||||
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
''
|
||||
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
||||
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
||||
|
||||
const error = await h.login.start().catch(e => e)
|
||||
expect(error).toBeInstanceOf(AiturkLoginError)
|
||||
expect(error.message).toBe('aiturk-login:invalid-response')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
// The issued code is revoked instead of being left approvable.
|
||||
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
})
|
||||
|
||||
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
||||
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
||||
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
})
|
||||
|
||||
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||
const pending = h.login.start()
|
||||
|
||||
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
|
||||
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
// One code only: the approval page is opened once.
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
})
|
||||
|
||||
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||
const failing = twice.login.start().catch(e => e)
|
||||
|
||||
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||
|
||||
for (const status of [500, 503, 429, 422]) {
|
||||
const answered = harness({ start: { status } })
|
||||
|
||||
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(answered.timers).toHaveLength(0)
|
||||
}
|
||||
|
||||
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||
|
||||
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(timedOut.timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects malformed codes', async () => {
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
})
|
||||
|
||||
it('keeps a single login: a second start revokes the first code', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
await h.login.start()
|
||||
|
||||
const urls = h.calls.map(c => c.url.split('/').pop())
|
||||
expect(urls).toEqual(['start', 'cancel', 'start'])
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
})
|
||||
|
||||
it('cancels the code when the browser cannot be opened', async () => {
|
||||
const h = harness({ openExternal: () => false })
|
||||
|
||||
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
||||
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: poll', () => {
|
||||
it('reports pending while the user has not approved', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
expect(h.calls[1].init.redirect).toBe('error')
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps waiting through a transient network error', async () => {
|
||||
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('reports expired on 401 and stops asking the server', async () => {
|
||||
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('expires by the clock without a request', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
h.clock.now += 600_000
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: '',
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
||||
profile: 'work',
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: 'account/model-a',
|
||||
models: ['account/model-a', 'account/model-b'],
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
||||
// No key ever rides a URL.
|
||||
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ask for the one-time keys again on later polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const first = await h.login.poll()
|
||||
const fetches = h.fetch.mock.calls.length
|
||||
const second = await h.login.poll()
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(h.fetch.mock.calls.length).toBe(fetches)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares one completion between overlapping polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
||||
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'ok'],
|
||||
[2, 'outdated'],
|
||||
[3, 'other-account'],
|
||||
[4, 'unknown-account'],
|
||||
[1, 'failed'],
|
||||
[null, 'failed'],
|
||||
['missing', 'missing']
|
||||
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
||||
})
|
||||
|
||||
it('treats a throwing CLI runner as failed without leaking', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
||||
})
|
||||
|
||||
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invent a model when the catalog is empty', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: true, reachable: true, models: [], message: '' }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never surfaces backend error text (which could echo the key)', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an approval whose key is malformed', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: other CLI account', () => {
|
||||
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
||||
await h.login.start()
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
it('overwrites only after the user confirms, without the keep flag', async () => {
|
||||
const h = await otherAccount()
|
||||
|
||||
const result = await h.login.overwriteCli()
|
||||
expect(result).toEqual({ cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
||||
|
||||
// Single use: the held key is gone.
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports an overwrite that still fails', async () => {
|
||||
const h = await otherAccount(4)
|
||||
|
||||
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
||||
})
|
||||
|
||||
it('drops the held CLI key after five minutes', async () => {
|
||||
const h = await otherAccount()
|
||||
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
||||
expect(hold).toBeDefined()
|
||||
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
hold?.fn()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a stale held key even if the timer never fired', async () => {
|
||||
const h = await otherAccount()
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the held CLI key on cancel', async () => {
|
||||
const h = await otherAccount()
|
||||
await h.login.cancel()
|
||||
|
||||
const error = await h.login.overwriteCli().catch(e => e)
|
||||
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
await h.login.poll()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: cancel', () => {
|
||||
it('revokes the code on the server and clears the session', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
const last = h.calls.at(-1)
|
||||
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('is a no-op without a login and survives a failing server', async () => {
|
||||
const h = harness()
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
expect(h.fetch).not.toHaveBeenCalled()
|
||||
|
||||
await h.login.start()
|
||||
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk CLI runner', () => {
|
||||
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
||||
|
||||
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
||||
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
||||
|
||||
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdin = new EventEmitter()
|
||||
|
||||
child.stdin.end = (chunk: string) => {
|
||||
state.stdin.push(chunk)
|
||||
queueMicrotask(() => behaviour(child))
|
||||
}
|
||||
|
||||
child.kill = () => {
|
||||
state.killed = true
|
||||
}
|
||||
|
||||
state.calls.push({ command, args, options })
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
return { spawn, state }
|
||||
}
|
||||
|
||||
it('finds the CLI only at the fixed install path', () => {
|
||||
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reports missing without spawning anything', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
||||
|
||||
const { command, args, options } = state.calls[0]
|
||||
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
expect(leaks([command, args, options])).toBe(false)
|
||||
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
||||
})
|
||||
|
||||
it('drops the keep flag for a confirmed overwrite', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
await run(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
||||
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
||||
})
|
||||
|
||||
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
||||
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const thrower = vi.fn(() => {
|
||||
throw new Error('spawn EINVAL')
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const epipe = fakeSpawn(child => {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
child.emit('close', 2)
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('kills a CLI that hangs past the timeout', async () => {
|
||||
const { spawn, state } = fakeSpawn(() => {})
|
||||
|
||||
let fire = () => {}
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env,
|
||||
platform: 'win32',
|
||||
setTimer: (fn, ms) => {
|
||||
expect(ms).toBe(60_000)
|
||||
fire = fn
|
||||
|
||||
return 1
|
||||
},
|
||||
clearTimer: () => {}
|
||||
})
|
||||
|
||||
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
||||
fire()
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(state.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an install path cmd.exe could reinterpret', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* aiturk-login.ts
|
||||
*
|
||||
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||||
*
|
||||
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||||
* page, and once the user approves, the server hands out two API keys exactly
|
||||
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||||
* process: they are not returned to the renderer, not logged, not put into
|
||||
* error messages, process arguments or URLs.
|
||||
* - The CLI receives its key on stdin only, is launched from an absolute
|
||||
* path without a shell, with a fixed argument list.
|
||||
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||||
* time out after 20 s.
|
||||
*
|
||||
* All I/O is injected so the logic is unit-testable without Electron, a
|
||||
* network or a real CLI (see aiturk-login.test.ts).
|
||||
*/
|
||||
|
||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
const AITURK_PROVIDER_ID = 'turkservis'
|
||||
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||||
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
|
||||
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||||
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||||
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||||
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||||
const MAX_EXPIRES_IN_S = 3600
|
||||
|
||||
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export type AiturkLoginErrorCode =
|
||||
| 'busy'
|
||||
| 'rate-limited'
|
||||
| 'unavailable'
|
||||
| 'invalid-response'
|
||||
| 'browser-failed'
|
||||
| 'provider-failed'
|
||||
| 'no-models'
|
||||
| 'nothing-to-overwrite'
|
||||
|
||||
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||||
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||||
export class AiturkLoginError extends Error {
|
||||
code: AiturkLoginErrorCode
|
||||
|
||||
constructor(code: AiturkLoginErrorCode) {
|
||||
super(`aiturk-login:${code}`)
|
||||
this.name = 'AiturkLoginError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiturkLoginScope {
|
||||
profile?: null | string
|
||||
connectionId?: null | string
|
||||
}
|
||||
|
||||
export interface AiturkLoginStartResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPollResult =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliResult }
|
||||
| { state: 'error'; error: AiturkLoginErrorCode }
|
||||
|
||||
export interface AiturkFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type AiturkFetch = (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||||
) => Promise<AiturkFetchResponse>
|
||||
|
||||
export interface AiturkBackendRequest {
|
||||
path: string
|
||||
method: string
|
||||
body: unknown
|
||||
profile?: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||||
* numeric exit code otherwise, null when it could not run / timed out. */
|
||||
export type AiturkCliExit = 'missing' | null | number
|
||||
|
||||
export interface AiturkLoginDeps {
|
||||
fetch: AiturkFetch
|
||||
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||||
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||||
hostname: () => string
|
||||
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||||
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||||
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||||
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||||
now: () => number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||||
}
|
||||
|
||||
interface Session {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
scope: { profile?: string; connectionId?: string }
|
||||
}
|
||||
|
||||
function deviceName(raw: unknown): string {
|
||||
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||||
|
||||
return cleaned || 'AITURK IDE'
|
||||
}
|
||||
|
||||
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||||
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||||
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||||
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||||
|
||||
return {
|
||||
...(profile ? { profile } : {}),
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||||
if (exit === 'missing') {
|
||||
return 'missing'
|
||||
}
|
||||
|
||||
if (exit === 0) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
if (exit === 2) {
|
||||
return 'outdated'
|
||||
}
|
||||
|
||||
if (exit === 3 && keepOtherAccount) {
|
||||
return 'other-account'
|
||||
}
|
||||
|
||||
if (exit === 4) {
|
||||
return 'unknown-account'
|
||||
}
|
||||
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
|
||||
const timeoutSignal =
|
||||
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||||
|
||||
let session: null | Session = null
|
||||
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||||
// polls share it so the one-time key is fetched and consumed exactly once.
|
||||
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||||
// Final outcome of the last login, replayed to later polls without a fetch.
|
||||
let settled: null | AiturkLoginPollResult = null
|
||||
// CLI key kept ONLY while the user decides whether to replace another
|
||||
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||||
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||||
|
||||
function dropHeldCli() {
|
||||
if (heldCli) {
|
||||
clearTimer(heldCli.timer)
|
||||
heldCli = null
|
||||
}
|
||||
}
|
||||
|
||||
function holdCli(key: string) {
|
||||
dropHeldCli()
|
||||
|
||||
const timer = setTimer(() => {
|
||||
heldCli = null
|
||||
}, AITURK_CLI_KEY_HOLD_MS)
|
||||
|
||||
// Never keep the app alive just for this timer.
|
||||
;(timer as { unref?: () => void } | null)?.unref?.()
|
||||
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||||
}
|
||||
|
||||
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||||
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
redirect: 'error',
|
||||
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelOnServer(deviceCode: string) {
|
||||
try {
|
||||
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||||
} catch {
|
||||
// Best effort: the code expires on its own within 10 minutes.
|
||||
}
|
||||
}
|
||||
|
||||
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||||
if (finishing) {
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
// One login at a time: a new start replaces (and revokes) the previous one.
|
||||
const previous = session
|
||||
session = null
|
||||
settled = null
|
||||
dropHeldCli()
|
||||
|
||||
if (previous) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse | undefined
|
||||
|
||||
// A long-idle process can fail its first request on a dead pooled
|
||||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||
// one start cannot knowingly leave two codes behind. A timeout already
|
||||
// used its whole budget and is not retried.
|
||||
for (let attempt = 1; response === undefined; attempt++) {
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch (error) {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new AiturkLoginError('rate-limited')
|
||||
}
|
||||
|
||||
if (response.status !== 201 && response.status !== 200) {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||||
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||||
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||||
const expiresIn = Number(data?.expiresIn)
|
||||
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||||
|
||||
if (
|
||||
!codeOk ||
|
||||
!USER_CODE_RE.test(userCode) ||
|
||||
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
// Never open a URL that is not exactly our approval page.
|
||||
if (codeOk) {
|
||||
await cancelOnServer(deviceCode)
|
||||
}
|
||||
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
if (session || finishing) {
|
||||
// A concurrent start won the race while we were waiting on the network.
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||||
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||||
|
||||
let opened: unknown
|
||||
|
||||
try {
|
||||
opened = await deps.openExternal(verificationUri)
|
||||
} catch {
|
||||
opened = false
|
||||
}
|
||||
|
||||
if (opened === false) {
|
||||
session = null
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('browser-failed')
|
||||
}
|
||||
|
||||
return { userCode, verificationUri, expiresAt }
|
||||
}
|
||||
|
||||
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||||
const endpoint = {
|
||||
id: AITURK_PROVIDER_ID,
|
||||
name: AITURK_PROVIDER_NAME,
|
||||
base_url: AITURK_PROVIDER_BASE_URL,
|
||||
api_key: apiKey,
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
|
||||
let checked: any
|
||||
|
||||
try {
|
||||
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||||
// saveCustomEndpoint (profile-scoped) calls.
|
||||
checked = await deps.backendRequest({
|
||||
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: '' }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (!checked?.ok) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||||
|
||||
if (!models.length) {
|
||||
throw new AiturkLoginError('no-models')
|
||||
}
|
||||
|
||||
let saved: any
|
||||
|
||||
try {
|
||||
saved = await deps.backendRequest({
|
||||
...scope,
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: models[0], models }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (saved?.ok === false) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||||
let exit: AiturkCliExit
|
||||
|
||||
try {
|
||||
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||||
} catch {
|
||||
exit = null
|
||||
}
|
||||
|
||||
return cliResultFromExit(exit, keepOtherAccount)
|
||||
}
|
||||
|
||||
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||||
try {
|
||||
await saveProvider(key, scope)
|
||||
} catch (error) {
|
||||
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||||
}
|
||||
|
||||
let cli: AiturkCliResult = 'failed'
|
||||
|
||||
if (cliKey) {
|
||||
cli = await handCliKey(cliKey, true)
|
||||
|
||||
if (cli === 'other-account') {
|
||||
holdCli(cliKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { state: 'connected', cli }
|
||||
}
|
||||
|
||||
async function poll(): Promise<AiturkLoginPollResult> {
|
||||
if (finishing) {
|
||||
return finishing
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
return settled
|
||||
}
|
||||
|
||||
const current = session
|
||||
|
||||
if (!current) {
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (deps.now() >= current.expiresAt) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||||
} catch {
|
||||
// Transient network trouble: keep waiting until the code expires.
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
// Cancelled or replaced while the request was in flight.
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (data?.state !== 'approved') {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
// The server returns the keys exactly once; the device code is spent.
|
||||
session = null
|
||||
|
||||
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||||
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||||
|
||||
if (!key) {
|
||||
settled = { state: 'error', error: 'invalid-response' }
|
||||
|
||||
return settled
|
||||
}
|
||||
|
||||
finishing = finish(key, cliKey, current.scope)
|
||||
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||||
.then(result => {
|
||||
settled = result
|
||||
finishing = null
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return finishing
|
||||
}
|
||||
|
||||
async function cancel(): Promise<{ ok: true }> {
|
||||
const current = session
|
||||
session = null
|
||||
dropHeldCli()
|
||||
|
||||
if (!finishing) {
|
||||
settled = null
|
||||
}
|
||||
|
||||
if (current) {
|
||||
await cancelOnServer(current.deviceCode)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||||
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||||
const held = heldCli
|
||||
|
||||
if (!held || deps.now() >= held.expiresAt) {
|
||||
dropHeldCli()
|
||||
|
||||
throw new AiturkLoginError('nothing-to-overwrite')
|
||||
}
|
||||
|
||||
// Single use: drop it before launching so it cannot be replayed.
|
||||
dropHeldCli()
|
||||
|
||||
const cli = await handCliKey(held.key, false)
|
||||
|
||||
if (settled?.state === 'connected') {
|
||||
settled = { state: 'connected', cli }
|
||||
}
|
||||
|
||||
return { cli }
|
||||
}
|
||||
|
||||
return { start, poll, cancel, overwriteCli }
|
||||
}
|
||||
|
||||
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiturkCliRunnerDeps {
|
||||
/** child_process.spawn (injected for tests). */
|
||||
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||||
existsSync: (path: string) => boolean
|
||||
env: Record<string, string | undefined>
|
||||
platform?: string
|
||||
timeoutMs?: number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
}
|
||||
|
||||
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||||
// carries either (or a control char) is refused rather than risk reparsing.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||||
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||||
|
||||
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||||
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||||
|
||||
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||||
|
||||
try {
|
||||
return deps.existsSync(candidate) ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||||
* stdin and nowhere else: the argument list is a fixed array with no user
|
||||
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||||
*/
|
||||
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||||
|
||||
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||||
const cliPath = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cliPath) {
|
||||
return Promise.resolve('missing')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||||
|
||||
// A .cmd cannot be spawned directly without a shell; run it through an
|
||||
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||||
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||||
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false
|
||||
let timer: unknown = null
|
||||
let child: any
|
||||
|
||||
const settle = (value: AiturkCliExit) => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
|
||||
if (timer !== null) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
} catch {
|
||||
settle(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
|
||||
settle(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('error', () => settle(null))
|
||||
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||||
|
||||
try {
|
||||
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||||
// the exit code is the answer.
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.end(`${cliKey}\n`)
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+180
-13
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -9,6 +9,7 @@ import tls from 'node:tls'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { startChromeBridge } from './chrome-bridge.mjs'
|
||||
import { createMobileWindow } from './mobile-host.mjs'
|
||||
import { MobileVault } from './mobile-secrets.mjs'
|
||||
|
||||
import {
|
||||
app,
|
||||
@@ -31,8 +32,19 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
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,
|
||||
createWindowsFirewall,
|
||||
defaultRouteAddress,
|
||||
type MobileLink,
|
||||
selectLanAddresses
|
||||
} from './mobile-lan-proxy'
|
||||
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||
@@ -2064,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) => {
|
||||
@@ -5248,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
|
||||
}
|
||||
|
||||
@@ -6784,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) {
|
||||
@@ -17231,6 +17252,53 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── AITURK one-click sign-in (device code) ───────────────────────────────
|
||||
// The API keys returned by turkservis.online stay in THIS process: the
|
||||
// provider is saved through the same backend route hermes:api uses and the
|
||||
// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees
|
||||
// the short user code, the approval URL and a status. See aiturk-login.ts.
|
||||
const aiturkLogin = createAiturkLogin({
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
openExternal: url => openExternalUrl(url),
|
||||
hostname: () => os.hostname(),
|
||||
backendRequest: request => handleHermesApiRequest(request),
|
||||
runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }),
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) {
|
||||
throw new Error('aiturk-login:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:login:start', async (event, scope) => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.start(scope)
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:poll', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.poll()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:cancel', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.cancel()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:overwrite-cli', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.overwriteCli()
|
||||
})
|
||||
|
||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||
@@ -17588,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())
|
||||
@@ -17770,19 +17841,110 @@ function openMobileConnectionWindow() {
|
||||
return
|
||||
}
|
||||
mobileConnectionWindow = createMobileWindow({
|
||||
directory: path.join(app.getPath('userData'), 'aiturk-mobile'),
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
resolveBackend: async () => {
|
||||
const profile = primaryProfileKey()
|
||||
if (globalRemoteActive() || profileHasRemoteOverride(profile)) throw new Error('Mobil bağlantı için yerel IDE bağlantısını seçin.')
|
||||
return ensureBackend(profile)
|
||||
}
|
||||
})
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
link: getMobileLink()
|
||||
})
|
||||
}
|
||||
|
||||
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
|
||||
// the loopback backend + pairing. One controller for the whole app run; it only
|
||||
// listens while the feature is on and a phone is paired or being paired.
|
||||
let mobileLink: MobileLink | null = null
|
||||
|
||||
function mobileLinkRemoteMode() {
|
||||
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
|
||||
}
|
||||
|
||||
function getMobileLink(): MobileLink {
|
||||
if (mobileLink) {
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
// 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: 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,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||
// 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
|
||||
|
||||
resolve({ code, stdout: String(stdout ?? '') })
|
||||
}
|
||||
)
|
||||
})
|
||||
}),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||
WebSocket: RelayWebSocket,
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may open the phone window.
|
||||
ipcMain.handle('aiturk:mobile:open', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('aiturk-mobile:unsupported')
|
||||
}
|
||||
|
||||
openMobileConnectionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
void mobileLink?.stop().catch(() => undefined)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Explicit development/beta entry point; never enables remote access on boot.
|
||||
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
||||
// Resume the phone link only when the user turned it on earlier AND a phone
|
||||
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
|
||||
if (process.platform === 'win32') {
|
||||
void getMobileLink()
|
||||
.start()
|
||||
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
|
||||
}
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
void startChromeBridge({
|
||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||
@@ -17793,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 }
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,169 @@
|
||||
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
|
||||
import { drawQr, InviteScanner } from './mobile/qr.mjs'
|
||||
import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.mjs'
|
||||
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
|
||||
const status = text => { $('status').textContent = text }
|
||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||
const identityScanner = new InviteScanner(document.createElement('video'), async raw => {
|
||||
if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.')
|
||||
const value = JSON.parse(raw)
|
||||
const pairingId = $('pairing').value.trim() || value.pairingId
|
||||
parsePhoneIdentity(raw, pairingId)
|
||||
if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.')
|
||||
$('pairing').value = pairingId; $('phone-identity').value = raw
|
||||
status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.')
|
||||
})
|
||||
$('phone-identity-file').onchange = async event => {
|
||||
try { await identityScanner.image(event.target.files[0]) }
|
||||
catch (error) { status(error.message) }
|
||||
finally { event.target.value = '' }
|
||||
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
|
||||
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
|
||||
/* global window, document */
|
||||
import { drawQr } from './mobile/qr.mjs'
|
||||
|
||||
const TEXT = {
|
||||
tr: {
|
||||
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||
toggle: 'Telefon bağlantısını kullan',
|
||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||
errors: {
|
||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||
toggle: 'Use phone connection',
|
||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
|
||||
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||
errors: {
|
||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||
not_enabled: 'Turn phone connection on first.',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||
failed: 'That did not work. Try again.',
|
||||
},
|
||||
},
|
||||
}
|
||||
action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` })
|
||||
action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') })
|
||||
action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') })
|
||||
async function desktopIdentity(create = false) {
|
||||
const setup = await host.call('setup')
|
||||
if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.')
|
||||
if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint)
|
||||
if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.')
|
||||
const identity = await createDeviceIdentity(setup.deviceId)
|
||||
await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint })
|
||||
return identity
|
||||
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
|
||||
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
|
||||
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||
const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed
|
||||
|
||||
function tick() {
|
||||
const pairing = current?.pairing
|
||||
if (!pairing) return
|
||||
const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))
|
||||
$('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0'))
|
||||
if (left === 0) { notice(t.expired); void refresh() }
|
||||
}
|
||||
action('invite', async () => {
|
||||
const identity = await desktopIdentity(true), invite = await host.call('invite.create')
|
||||
drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint }))
|
||||
$('invite-qr').hidden = false
|
||||
$('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}`
|
||||
})
|
||||
action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) })
|
||||
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
|
||||
action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` })
|
||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') })
|
||||
action('refresh', async () => {
|
||||
const data = await host.call('devices'); $('devices').replaceChildren()
|
||||
for (const device of data.devices) {
|
||||
const row = document.createElement('p'), label = document.createElement('span'), button = document.createElement('button')
|
||||
label.textContent = `${device.name} · ${device.platform}${device.self ? ' · Bu bilgisayar' : ''}`
|
||||
button.textContent = 'Cihazı iptal et'
|
||||
button.onclick = async () => { button.disabled = true; try { await host.call('device.revoke', { deviceId: device.id, version: device.version }); row.remove(); status('Cihaz iptal edildi.') } catch (error) { status(error.message); button.disabled = false } }
|
||||
row.append(label, button); $('devices').append(row)
|
||||
}
|
||||
})
|
||||
function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) }
|
||||
host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) })
|
||||
async function connect(offer) {
|
||||
assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint)
|
||||
const ice = await host.call('ice')
|
||||
const identity = await desktopIdentity()
|
||||
const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry)
|
||||
pc.ondatachannel = event => {
|
||||
const channel = event.channel; entry.channel = channel
|
||||
if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return }
|
||||
let pending = 0
|
||||
channel.onmessage = async event => {
|
||||
if (typeof event.data !== 'string' || event.data.length > 65000 || ++pending > 8) { close(offer.id); return }
|
||||
let message
|
||||
try {
|
||||
message = JSON.parse(event.data)
|
||||
const result = await host.call('channel.request', { channelId: offer.id, message })
|
||||
if (channel.readyState === 'open') sendFrame(channel, { id: message.id, result })
|
||||
} catch (error) { if (message?.id && channel.readyState === 'open') channel.send(JSON.stringify({ id: message.id, error: error.message })) }
|
||||
finally { pending-- }
|
||||
}
|
||||
channel.onclose = () => close(offer.id)
|
||||
channel.onopen = () => status('Telefon şifreli veri kanalıyla bağlı.')
|
||||
}
|
||||
try {
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
|
||||
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
||||
await host.call('channel.answer', { channelId: offer.id, sdp: pc.localDescription.sdp })
|
||||
entry.timer = setInterval(() => { void host.call('channel.heartbeat', { channelId: offer.id }).catch(() => close(offer.id)) }, 10000)
|
||||
} catch (error) { close(offer.id); throw error }
|
||||
|
||||
function render(state) {
|
||||
current = state
|
||||
$('enabled').checked = state.enabled
|
||||
$('remote-box').hidden = !state.remote
|
||||
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||
$('status-box').hidden = !state.enabled || state.remote
|
||||
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||
const noRule = lan.direct && lan.firewall === 'missing'
|
||||
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||
$('lan-disallow').hidden = !lan.direct
|
||||
$('pair-section').hidden = !state.enabled || state.remote
|
||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||
if (state.pairing) {
|
||||
if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr }
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
|
||||
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
|
||||
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||
tick()
|
||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||
$('devices-empty').hidden = state.devices.length > 0
|
||||
$('devices').replaceChildren(...state.devices.map(device => {
|
||||
const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong')
|
||||
const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button')
|
||||
row.className = 'device'; name.textContent = device.name
|
||||
added.textContent = `${t.added}: ${when(device.createdAt)}`
|
||||
seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}`
|
||||
button.textContent = t.remove; button.className = 'danger'
|
||||
button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) })
|
||||
label.append(name, added, seen); row.append(label, button)
|
||||
return row
|
||||
}))
|
||||
$('footer').hidden = !state.enabled || state.remote
|
||||
$('relay-renew').hidden = !relay.ready
|
||||
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||
}
|
||||
let listening = false
|
||||
action('listen', async () => {
|
||||
if (listening) return
|
||||
listening = true; status('Onaylı telefon bağlantısı bekleniyor.')
|
||||
while (listening) {
|
||||
try { const data = await host.call('inbox'); for (const offer of data.channels) if (!peers.has(offer.id)) await connect(offer) }
|
||||
catch (error) { status(error.message); listening = false; break }
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
})
|
||||
window.addEventListener('pagehide', () => { listening = false; for (const id of peers.keys()) close(id) })
|
||||
|
||||
async function run(work) {
|
||||
if (busy) return
|
||||
busy = true; document.body.setAttribute('aria-busy', 'true')
|
||||
for (const button of document.querySelectorAll('button,input')) button.disabled = true
|
||||
try { await work() } catch (error) { notice(errorText(error)); await refresh() }
|
||||
finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false }
|
||||
}
|
||||
async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(error)) } }
|
||||
|
||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
|
||||
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||
setInterval(tick, 1000)
|
||||
// "Last seen" and the live marker change without an event; keep the list fresh while the window is open.
|
||||
setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000)
|
||||
void refresh()
|
||||
@@ -0,0 +1,27 @@
|
||||
/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */
|
||||
.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer }
|
||||
.switch input { width:26px; height:26px; margin:0; flex:none }
|
||||
.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 }
|
||||
.warning p { color:var(--text); margin:4px 0 }
|
||||
.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all }
|
||||
.countdown { text-align:center; color:var(--text); font-weight:600 }
|
||||
.hint { text-align:center }
|
||||
#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px }
|
||||
#pair-live p { text-align:center }
|
||||
.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 }
|
||||
.device strong { color:var(--text); display:block; overflow-wrap:anywhere }
|
||||
.device small { display:block }
|
||||
.device button { flex:none; margin:0 }
|
||||
footer { margin-top:34px }
|
||||
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||
.status-actions button { margin:0; min-height:44px }
|
||||
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||
footer button.link { min-height:44px }
|
||||
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
|
||||
@@ -1,24 +1,34 @@
|
||||
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'">
|
||||
<title>AITURK Mobil bağlantısı</title><link rel="stylesheet" href="mobile/style.css"></head><body><main>
|
||||
<p class="eyebrow">AITURK · BİLGİSAYAR</p><h1>Telefonunu bağla.</h1>
|
||||
<p>Bu pencere açıkken eşleştirdiğiniz Android telefon seçtiğiniz IDE konuşmasına erişebilir. Google anahtarı Windows güvenli kasasında tutulur.</p>
|
||||
<p id="status" role="status" aria-live="polite">Bağlantı kapalı.</p>
|
||||
<button id="login-start">AITURK hesabımla giriş yap</button><p id="login-code"></p><button id="login-check">Girişi kontrol et</button>
|
||||
<p>Çıkış, mobil bağlantıları kapatır ve bu penceredeki hesap/Google anahtarını Windows kasasından temizler; başlamış IDE işini durdurmaz.</p>
|
||||
<button id="logout">Mobil oturumdan çık ve kasayı temizle</button>
|
||||
<details><summary>Gelişmiş: hesap API anahtarı</summary>
|
||||
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
|
||||
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
|
||||
<p id="code"></p><hr>
|
||||
<button id="invite">Telefon için QR oluştur</button><canvas id="invite-qr" hidden aria-label="Süreli telefon eşleştirme QR kodu"></canvas>
|
||||
<p id="invite-status"></p><button id="invite-check">QR ile gelen telefonu getir</button>
|
||||
<h2>Telefona izin ver</h2><p>Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.</p>
|
||||
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
|
||||
<label>Telefondan alınan kimlik belgesi<textarea id="phone-identity" maxlength="700"></textarea></label>
|
||||
<label>Telefon kimliği QR resmi<input id="phone-identity-file" type="file" accept="image/*"></label>
|
||||
<p>Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.</p>
|
||||
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
|
||||
<h2>Cihazlar</h2><button id="refresh">Cihaz listesini yenile</button><div id="devices"></div>
|
||||
<p>Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.</p>
|
||||
</main><script src="mobile/vendor/jsQR.js"></script><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'">
|
||||
<title>AITURK · Telefon bağlantısı</title><link rel="stylesheet" href="mobile/style.css"><link rel="stylesheet" href="mobile-host.css"></head><body><main>
|
||||
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</h1>
|
||||
<p id="t-intro"></p>
|
||||
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||
<section id="status-box" hidden>
|
||||
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||
</section>
|
||||
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||
<section id="pair-section" hidden>
|
||||
<button id="pair-begin"></button>
|
||||
<div id="pair-live" hidden>
|
||||
<p id="t-scan"></p>
|
||||
<canvas id="pair-qr" role="img"></canvas>
|
||||
<p id="t-or-code"></p>
|
||||
<p id="pair-code" class="code" aria-live="off"></p>
|
||||
<p id="pair-code-path"></p>
|
||||
<button id="pair-republish" class="link" hidden></button>
|
||||
<p id="pair-countdown" class="countdown"></p>
|
||||
<p id="pair-security" class="security"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
<button id="pair-cancel" class="secondary"></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="devices-section" hidden>
|
||||
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||
</section>
|
||||
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
@@ -1,157 +1,52 @@
|
||||
// "Telefon bağlantısı" window: a thin IPC shell around the controller built in
|
||||
// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the
|
||||
// main process for the whole app run — closing this window only cancels a
|
||||
// pairing in progress; paired phones stay connected.
|
||||
import { join } from 'node:path'
|
||||
import { hostname } from 'node:os'
|
||||
import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
|
||||
import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs'
|
||||
import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs'
|
||||
import { AccountClient } from '../../mobile/src/transport.mjs'
|
||||
import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
export function createMobileWindow({ directory, assets, resolveBackend, show = true }) {
|
||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||
const journal = new DeliveryJournal(join(directory, 'mobile', 'deliveries'))
|
||||
const issuer = new LiveTokenIssuer(vault)
|
||||
const clients = new Map()
|
||||
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK Mobil bağlantısı',
|
||||
const CODED = /^aiturk-mobile:[a-z_]+$/
|
||||
|
||||
export function createMobileWindow({ assets, link, show = true }) {
|
||||
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı',
|
||||
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
|
||||
nodeIntegration: false, sandbox: true, backgroundThrottling: false } })
|
||||
nodeIntegration: false, sandbox: true } })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
window.webContents.on('will-navigate', event => event.preventDefault())
|
||||
const api = async () => new AccountClient(await vault.load())
|
||||
const call = async (path, body, method) => (await api()).call(path, body, method)
|
||||
const send = frame => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', frame) }
|
||||
const handler = async (event, action, params = {}) => {
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.')
|
||||
if (action === 'setup') {
|
||||
const secrets = await vault.load()
|
||||
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey),
|
||||
deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint }
|
||||
}
|
||||
if (action === 'identity.save') {
|
||||
const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint)
|
||||
if (!previous.deviceId || previous.deviceId !== params.deviceId ||
|
||||
(previous.localFingerprint && previous.localFingerprint !== fingerprint))
|
||||
throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.')
|
||||
await vault.save({ ...previous, localFingerprint: fingerprint })
|
||||
return { saved: true }
|
||||
}
|
||||
if (action === 'account.save') {
|
||||
if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.')
|
||||
if (clients.size) throw new Error('Hesap değiştirmeden önce mobil bağlantıyı kapatın.')
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken && previous.accountToken !== params.token) throw new Error('Hesabı değiştirmek için önce mevcut cihazı iptal edin ve bağlantı penceresini yeniden açın.')
|
||||
await vault.save({ ...previous, accountToken: params.token })
|
||||
return { saved: true }
|
||||
}
|
||||
if (action === 'account.login.start') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Kayıtlı hesap var. Önce mevcut cihazı iptal edin.')
|
||||
const login = await new AccountClient({}).call('/v1/aiturk/mobile-auth/start', {})
|
||||
if (!/^https:\/\/turkservis\.online\/account\/aiturk\/mobile-login\/[0-9a-f-]{36}$/.test(login.verificationUri)) throw new Error('Geçersiz giriş adresi.')
|
||||
await vault.save({ ...previous, pendingLogin: login })
|
||||
await shell.openExternal(login.verificationUri)
|
||||
return { code: login.userCode }
|
||||
}
|
||||
if (action === 'account.login.check') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Mevcut hesap veya bağlantı sessizce değiştirilemez.')
|
||||
if (!previous.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||
const result = await new AccountClient({ accountToken: previous.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||
if (result.state !== 'approved') return { approved: false }
|
||||
delete previous.pendingLogin
|
||||
await vault.save({ ...previous, accountToken: result.accountToken })
|
||||
return { approved: true }
|
||||
}
|
||||
if (action === 'account.logout') {
|
||||
const previous = await vault.load(), token = previous.accountToken || previous.pendingLogin?.deviceCode
|
||||
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
|
||||
for (const [id, entry] of clients) { entry.client.close(); send({ channelId: id, close: true }) }
|
||||
clients.clear(); await vault.save({})
|
||||
return { cleared: true }
|
||||
}
|
||||
if (action === 'desktop.pair') {
|
||||
const result = await call('/v1/aiturk/pairings', { display_name: hostname(), platform: 'windows-x64' })
|
||||
await vault.save({ ...await vault.load(), deviceId: result.device_id, deviceToken: result.device_token,
|
||||
pairingId: result.pairing_id })
|
||||
await shell.openExternal('https://turkservis.online' + result.verification_uri)
|
||||
return { code: result.user_code, expiresAt: result.expires_at }
|
||||
}
|
||||
if (action === 'phone.approve') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.')
|
||||
const identity = parsePhoneIdentity(params.identity, params.pairingId)
|
||||
const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId]
|
||||
if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.')
|
||||
const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
||||
if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.')
|
||||
const current = await vault.load()
|
||||
if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.')
|
||||
await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } })
|
||||
return result
|
||||
}
|
||||
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
|
||||
if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {})
|
||||
if (action === 'invite.check') return call('/v1/aiturk/mobile/invites')
|
||||
if (action === 'device.revoke') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.deviceId) || !Number.isInteger(params.version)) throw new Error('Geçersiz cihaz.')
|
||||
const result = await call(`/v1/aiturk/devices/${params.deviceId}/revoke`, { expected_version: params.version })
|
||||
for (const [id, entry] of clients) if (entry.phoneId === params.deviceId || result.self_revoked) {
|
||||
entry.client.close(); clients.delete(id); send({ channelId: id, close: true })
|
||||
}
|
||||
if (result.self_revoked) await vault.save({})
|
||||
return result
|
||||
}
|
||||
if (action === 'inbox') {
|
||||
const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load()
|
||||
return { ...data, channels: data.channels.map(offer => {
|
||||
const fingerprint = secrets.phoneFingerprints?.[offer.phone_id]
|
||||
assertRemoteIdentity(offer.sdp, fingerprint)
|
||||
return { ...offer, trustedPhoneFingerprint: fingerprint }
|
||||
}) }
|
||||
}
|
||||
if (action === 'ice') return call('/v1/aiturk/mobile/ice')
|
||||
if (action === 'channel.answer') {
|
||||
const inbox = await call('/v1/aiturk/mobile/channels')
|
||||
const offer = inbox.channels.find(row => row.id === params.channelId)
|
||||
if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.')
|
||||
assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id])
|
||||
const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {})
|
||||
const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }),
|
||||
{ deviceId: offer.phone_id, journal, authorize })
|
||||
await call(`/v1/aiturk/mobile/channels/${offer.id}/answer`, { sdp: params.sdp })
|
||||
clients.set(offer.id, { client, phoneId: offer.phone_id, authorize })
|
||||
return { accepted: true }
|
||||
}
|
||||
if (action === 'channel.heartbeat') {
|
||||
const entry = clients.get(params.channelId)
|
||||
if (!entry) throw new Error('Bağlantı yok.')
|
||||
return entry.authorize()
|
||||
}
|
||||
if (action === 'channel.close') {
|
||||
clients.get(params.channelId)?.client.close(); clients.delete(params.channelId)
|
||||
return { closed: true }
|
||||
}
|
||||
if (action === 'channel.request') {
|
||||
const entry = clients.get(params.channelId)
|
||||
if (!entry) throw new Error('Bağlantı yok.')
|
||||
await entry.authorize()
|
||||
const message = params.message
|
||||
if (!message || JSON.stringify(message).length > 65000) throw new Error('İstek boyutu geçersiz.')
|
||||
if (message.action === 'google.key.save') {
|
||||
const key = message.params?.key
|
||||
if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{20,256}$/.test(key)) throw new Error('Google anahtarı biçimi geçersiz.')
|
||||
await vault.save({ ...await vault.load(), googleKey: key })
|
||||
return { saved: true }
|
||||
}
|
||||
if (message.action === 'google.key.delete') {
|
||||
const secrets = await vault.load(); delete secrets.googleKey; await vault.save(secrets); return { deleted: true }
|
||||
}
|
||||
if (message.action === 'voice.token') return issuer.issue(entry.phoneId)
|
||||
return entry.client.handle(message)
|
||||
}
|
||||
throw new Error('Desteklenmeyen mobil işlem.')
|
||||
const actions = {
|
||||
state: () => link.state(),
|
||||
enable: () => link.enable(),
|
||||
disable: () => link.disable(),
|
||||
'pair.begin': () => link.pairBegin(),
|
||||
'pair.cancel': () => link.pairCancel(),
|
||||
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||
'pair.republish': () => link.pairRepublish(),
|
||||
devices: () => link.devices(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
'lan.allow': () => link.lanAllow(),
|
||||
'lan.disallow': () => link.lanDisallow(),
|
||||
'firewall.allow': () => link.lanAllow(),
|
||||
'firewall.retry': () => link.lanAllow(),
|
||||
'relay.renew': () => link.relayRenew(),
|
||||
'firewall.remove': () => link.firewallRemove(),
|
||||
}
|
||||
const handler = async (event, action, params) => {
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden')
|
||||
if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported')
|
||||
try { return await actions[action](params) } catch (error) {
|
||||
// Only fixed codes cross to the renderer; never upstream error text.
|
||||
throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed')
|
||||
}
|
||||
}
|
||||
const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) })
|
||||
ipcMain.handle('aiturk-mobile', handler)
|
||||
window.on('closed', () => { for (const entry of clients.values()) entry.client.close(); clients.clear(); ipcMain.removeHandler('aiturk-mobile') })
|
||||
window.on('closed', () => {
|
||||
unsubscribe(); ipcMain.removeHandler('aiturk-mobile')
|
||||
// The code on screen is gone with the window; do not leave pairing open behind it.
|
||||
void link.pairCancel().catch(() => {})
|
||||
})
|
||||
void window.loadFile(join(assets, 'mobile-host.html'))
|
||||
return window
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed
|
||||
* certificate of the "Telefon bağlantısı" LAN proxy and its vault storage.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSelfSignedCertificate,
|
||||
fingerprintOfPem,
|
||||
isUsableIdentity,
|
||||
loadOrCreateLanIdentity
|
||||
} from './mobile-lan-cert'
|
||||
|
||||
function memoryVault(initial: Record<string, any> = {}) {
|
||||
const state = { value: structuredClone(initial), saves: 0 }
|
||||
|
||||
return {
|
||||
state,
|
||||
load: async () => structuredClone(state.value),
|
||||
save: async (value: Record<string, any>) => {
|
||||
state.value = structuredClone(value)
|
||||
state.saves++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSelfSignedCertificate', () => {
|
||||
it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => {
|
||||
const identity = createSelfSignedCertificate()
|
||||
const x509 = new crypto.X509Certificate(identity.cert)
|
||||
|
||||
expect(x509.verify(x509.publicKey)).toBe(true)
|
||||
expect(x509.publicKey.asymmetricKeyType).toBe('ec')
|
||||
expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||
expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true)
|
||||
expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3)
|
||||
expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now())
|
||||
})
|
||||
|
||||
it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => {
|
||||
const identity = createSelfSignedCertificate()
|
||||
const der = new crypto.X509Certificate(identity.cert).raw
|
||||
|
||||
expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url'))
|
||||
expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint)
|
||||
expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint)
|
||||
})
|
||||
|
||||
it('refuses a validity that UTCTime cannot carry', () => {
|
||||
expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow(
|
||||
'mobile-lan-cert:validity-out-of-range'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOrCreateLanIdentity', () => {
|
||||
it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => {
|
||||
const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } })
|
||||
const first = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(first.created).toBe(true)
|
||||
expect(vault.state.value.accountToken).toBe('old-field')
|
||||
expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' })
|
||||
expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY')
|
||||
|
||||
const second = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(second.created).toBe(false)
|
||||
expect(second.fingerprint).toBe(first.fingerprint)
|
||||
expect(vault.state.saves).toBe(1)
|
||||
})
|
||||
|
||||
it('replaces a corrupt, mismatched or nearly expired identity', async () => {
|
||||
const good = createSelfSignedCertificate()
|
||||
const other = createSelfSignedCertificate()
|
||||
|
||||
expect(isUsableIdentity(good)).toBe(true)
|
||||
expect(isUsableIdentity({ ...good, key: other.key })).toBe(false)
|
||||
expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false)
|
||||
expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false)
|
||||
expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false)
|
||||
|
||||
const vault = memoryVault({ lanTls: { ...good, key: other.key } })
|
||||
const renewed = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(renewed.created).toBe(true)
|
||||
expect(renewed.fingerprint).not.toBe(good.fingerprint)
|
||||
})
|
||||
|
||||
it('surfaces a vault failure instead of running without a stored key', async () => {
|
||||
const vault = {
|
||||
load: async () => {
|
||||
throw new Error('vault closed')
|
||||
},
|
||||
save: async () => undefined
|
||||
}
|
||||
|
||||
await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* mobile-lan-cert.ts
|
||||
*
|
||||
* Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy
|
||||
* (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built
|
||||
* by hand and signed with node:crypto.
|
||||
*
|
||||
* The phone never trusts a CA for this server. It pins the certificate by
|
||||
* `fp` = SHA-256 of the certificate's DER bytes, base64url without padding
|
||||
* (43 characters), which it learns from the pairing QR / rendezvous record.
|
||||
*
|
||||
* The private key lives only in the injected vault (MobileVault → Electron
|
||||
* safeStorage → Windows DPAPI). Existing vault fields are preserved.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
export const MOBILE_LAN_CERT_DAYS = 3650
|
||||
/** Regenerate when less than this is left; the phone must then pair again. */
|
||||
const RENEW_BEFORE_MS = 30 * 24 * 3600e3
|
||||
const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export interface MobileLanIdentity {
|
||||
/** PEM certificate. */
|
||||
cert: string
|
||||
/** PEM PKCS#8 private key. Never leaves the main process. */
|
||||
key: string
|
||||
/** SHA-256 of the DER certificate, base64url, 43 characters. */
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface MobileLanVault {
|
||||
load: () => Promise<Record<string, any>>
|
||||
save: (value: Record<string, any>) => Promise<void>
|
||||
}
|
||||
|
||||
function der(tag: number, ...parts: Buffer[]): Buffer {
|
||||
const body = Buffer.concat(parts)
|
||||
let length: Buffer
|
||||
|
||||
if (body.length < 128) {
|
||||
length = Buffer.from([body.length])
|
||||
} else {
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let n = body.length; n > 0; n >>= 8) {
|
||||
bytes.unshift(n & 255)
|
||||
}
|
||||
|
||||
length = Buffer.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
return Buffer.concat([Buffer.from([tag]), length, body])
|
||||
}
|
||||
|
||||
const seq = (...parts: Buffer[]) => der(0x30, ...parts)
|
||||
const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex'))
|
||||
const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value)
|
||||
|
||||
const utcTime = (date: Date) =>
|
||||
der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii'))
|
||||
|
||||
const ECDSA_SHA256 = seq(oid('2a8648ce3d040302'))
|
||||
const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8')))))
|
||||
|
||||
export function fingerprintOfDer(certDer: Uint8Array): string {
|
||||
return crypto.createHash('sha256').update(certDer).digest('base64url')
|
||||
}
|
||||
|
||||
/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */
|
||||
export function fingerprintOfPem(certPem: string): string {
|
||||
return fingerprintOfDer(new crypto.X509Certificate(certPem).raw)
|
||||
}
|
||||
|
||||
export function createSelfSignedCertificate(
|
||||
options: { commonName?: string; days?: number; now?: Date } = {}
|
||||
): MobileLanIdentity {
|
||||
const commonName = options.commonName ?? 'AITURK IDE'
|
||||
const days = options.days ?? MOBILE_LAN_CERT_DAYS
|
||||
const now = options.now ?? new Date()
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
|
||||
const serial = crypto.randomBytes(16)
|
||||
|
||||
serial[0] = (serial[0] & 0x7f) | 0x01
|
||||
|
||||
const notBefore = new Date(now.getTime() - 24 * 3600e3)
|
||||
const notAfter = new Date(now.getTime() + days * 24 * 3600e3)
|
||||
|
||||
if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) {
|
||||
// UTCTime cannot carry these years; refuse rather than emit a wrong date.
|
||||
throw new Error('mobile-lan-cert:validity-out-of-range')
|
||||
}
|
||||
|
||||
const tbs = seq(
|
||||
der(0xa0, int(Buffer.from([2]))), // v3
|
||||
int(serial),
|
||||
ECDSA_SHA256,
|
||||
name(commonName),
|
||||
seq(utcTime(notBefore), utcTime(notAfter)),
|
||||
name(commonName),
|
||||
publicKey.export({ type: 'spki', format: 'der' }) as Buffer
|
||||
)
|
||||
|
||||
const signature = crypto.sign('sha256', tbs, privateKey)
|
||||
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature))
|
||||
const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n')
|
||||
|
||||
return {
|
||||
cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`,
|
||||
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
||||
fingerprint: fingerprintOfDer(certDer)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored pair is a usable, unexpired, matching identity. */
|
||||
export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity {
|
||||
const row = value as MobileLanIdentity | null
|
||||
|
||||
if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(row.cert)
|
||||
|
||||
return (
|
||||
fingerprintOfDer(x509.raw) === row.fingerprint &&
|
||||
x509.checkPrivateKey(crypto.createPrivateKey(row.key)) &&
|
||||
new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the LAN identity from the vault, or create and store one. Stored under
|
||||
* the vault's `lanTls` field; every other field is written back untouched.
|
||||
* `created` tells the caller that previously paired phones no longer match.
|
||||
*/
|
||||
export async function loadOrCreateLanIdentity(
|
||||
vault: MobileLanVault,
|
||||
options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {}
|
||||
): Promise<MobileLanIdentity & { created: boolean }> {
|
||||
const now = options.now ? options.now() : new Date()
|
||||
const secrets = (await vault.load()) ?? {}
|
||||
|
||||
if (isUsableIdentity(secrets.lanTls, now)) {
|
||||
const { cert, key, fingerprint } = secrets.lanTls
|
||||
|
||||
return { cert, key, fingerprint, created: false }
|
||||
}
|
||||
|
||||
const identity = (options.create ?? createSelfSignedCertificate)({ now })
|
||||
|
||||
await vault.save({ ...secrets, lanTls: identity })
|
||||
|
||||
return { ...identity, created: true }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* mobile-relay-client.ts
|
||||
*
|
||||
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||
*
|
||||
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||
* controller keeps it in the safeStorage vault). It is sent in the
|
||||
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||
* log line, a status object or an error message.
|
||||
* - Log lines are status changes only.
|
||||
* - Errors are fixed codes.
|
||||
*
|
||||
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||
* relay on 127.0.0.1 and a fake clock.
|
||||
*/
|
||||
|
||||
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||
export const RELAY_SILENCE_MS = 75_000
|
||||
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||
export const RELAY_MAX_STREAMS = 16
|
||||
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||
|
||||
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||
const BUFFER_POLL_MS = 25
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||
|
||||
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||
export function isRelayId(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value)
|
||||
}
|
||||
|
||||
export interface RelayCredentials {
|
||||
relayId: string
|
||||
hostToken: string
|
||||
}
|
||||
|
||||
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||
const row = value as null | RelayCredentials
|
||||
|
||||
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||
}
|
||||
|
||||
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||
|
||||
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||
}
|
||||
|
||||
export interface RelayFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||
|
||||
export type RelayFailureCode =
|
||||
| 'register_failed'
|
||||
| 'rate_limited'
|
||||
| 'vault_unavailable'
|
||||
| 'connect_failed'
|
||||
| 'token_rejected'
|
||||
| 'replaced'
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||
|
||||
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
return name === 'TimeoutError' || name === 'AbortError'
|
||||
}
|
||||
|
||||
export interface NetworkRetryOptions {
|
||||
/** Injected wait (tests: fake clock). Default: a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* `POST /sessions`; a fixed failure code instead of any upstream text.
|
||||
*
|
||||
* A process that sat idle for a long time can fail its first request on a dead
|
||||
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
|
||||
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
|
||||
*/
|
||||
export async function registerRelaySession(
|
||||
fetcher: RelayFetch,
|
||||
options: NetworkRetryOptions = {}
|
||||
): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
const sleep = options.sleep ?? realSleep
|
||||
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let response: RelayFetchResponse
|
||||
|
||||
try {
|
||||
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
if (attempt >= 2 || isTimeoutError(error)) {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
|
||||
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the client needs from the outside ──────────────────────────────────
|
||||
|
||||
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||
export interface RelaySocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
/** A string is a text frame, a Buffer a binary frame. */
|
||||
send: (data: Buffer | string) => unknown
|
||||
close: (code?: number) => unknown
|
||||
terminate?: () => unknown
|
||||
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||
pause?: () => unknown
|
||||
resume?: () => unknown
|
||||
readonly bufferedAmount?: number
|
||||
}
|
||||
|
||||
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||
|
||||
export interface RelayWebSocketCtor {
|
||||
new (url: string, options?: Record<string, unknown>): any
|
||||
}
|
||||
|
||||
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||
export interface RelayLocalSocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
write: (data: Buffer) => boolean
|
||||
destroy: () => unknown
|
||||
pause: () => unknown
|
||||
resume: () => unknown
|
||||
readonly writableLength?: number
|
||||
}
|
||||
|
||||
/** null = the proxy is not listening right now. */
|
||||
export type RelayDial = () => null | RelayLocalSocket
|
||||
|
||||
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||
|
||||
frame.writeUInt32BE(id, 0)
|
||||
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
|
||||
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||
parts.push(chunk.subarray(offset, offset + size))
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||
|
||||
// ── The client ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||
|
||||
export interface RelayState {
|
||||
status: RelayStatus
|
||||
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||
code: null | RelayFailureCode
|
||||
}
|
||||
|
||||
export interface RelayCredentialStore {
|
||||
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||
load: () => Promise<null | RelayCredentials>
|
||||
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface RelayClientDeps {
|
||||
connect: RelayConnect
|
||||
fetch: RelayFetch
|
||||
dial: RelayDial
|
||||
credentials: RelayCredentialStore
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
/** Status changes only. */
|
||||
log?: (line: string) => void
|
||||
onState?: (state: RelayState) => void
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
local: RelayLocalSocket
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export function createRelayClient(deps: RelayClientDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||
|
||||
let state: RelayState = { status: 'off', code: null }
|
||||
let wanted = false
|
||||
let generation = 0
|
||||
let attempt = 0
|
||||
let socket: null | RelaySocket = null
|
||||
let credentials: null | RelayCredentials = null
|
||||
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||
let rejected = false
|
||||
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||
let retryTimer: unknown = null
|
||||
let silenceTimer: unknown = null
|
||||
const streams = new Map<number, Stream>()
|
||||
/** Same (injectable) clock as every other wait of this client. */
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||
|
||||
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||
if (state.status === status && state.code === code) {
|
||||
return
|
||||
}
|
||||
|
||||
state = { status, code }
|
||||
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||
|
||||
try {
|
||||
deps.onState?.(state)
|
||||
} catch {
|
||||
// a broken listener must not break the relay
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (retryTimer !== null) {
|
||||
clearTimer(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
silenceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored registration, or a new one. One request at a time. */
|
||||
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
if (credentials) {
|
||||
return Promise.resolve(credentials)
|
||||
}
|
||||
|
||||
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||
let stored: null | RelayCredentials
|
||||
|
||||
try {
|
||||
stored = await deps.credentials.load()
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
if (isRelayCredentials(stored)) {
|
||||
credentials = stored
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: rejected })
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
rejected = false
|
||||
credentials = fresh
|
||||
|
||||
return fresh
|
||||
})().finally(() => {
|
||||
registering = null
|
||||
})
|
||||
|
||||
return registering
|
||||
}
|
||||
|
||||
function dropStreams() {
|
||||
for (const stream of streams.values()) {
|
||||
stream.local.destroy()
|
||||
}
|
||||
|
||||
streams.clear()
|
||||
}
|
||||
|
||||
function retry(code: RelayFailureCode, delay?: number) {
|
||||
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||
|
||||
attempt++
|
||||
setState('failed', code)
|
||||
retryTimer = setTimer(() => {
|
||||
retryTimer = null
|
||||
void connect()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!wanted || socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const mine = ++generation
|
||||
|
||||
clearTimers()
|
||||
setState('connecting')
|
||||
|
||||
const current = await register()
|
||||
|
||||
if (!wanted || mine !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('error' in current) {
|
||||
retry(current.error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let ws: RelaySocket
|
||||
|
||||
try {
|
||||
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||
} catch {
|
||||
retry('connect_failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket = ws
|
||||
|
||||
const send = (data: Buffer | string) => {
|
||||
if (socket === ws) {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
// the close event follows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||
|
||||
const watch = () => {
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
}
|
||||
|
||||
silenceTimer = setTimer(() => {
|
||||
silenceTimer = null
|
||||
|
||||
if (socket === ws) {
|
||||
try {
|
||||
;(ws.terminate ?? ws.close).call(ws)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
|
||||
down(1006, 'timeout')
|
||||
}
|
||||
}, RELAY_SILENCE_MS)
|
||||
}
|
||||
|
||||
const down = (code: number, reason?: RelayFailureCode) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
socket = null
|
||||
clearTimers()
|
||||
dropStreams()
|
||||
|
||||
if (!wanted) {
|
||||
setState('off')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||
if (code === 4401 || code === 4404) {
|
||||
credentials = null
|
||||
rejected = true
|
||||
void deps.credentials
|
||||
.clear()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||
retry('token_rejected')
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 4409) {
|
||||
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4429: the relay service is full; the normal growing delay applies.
|
||||
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||
}
|
||||
|
||||
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||
const stalled = new Set<Stream>()
|
||||
|
||||
const unstall = (stream: Stream) => {
|
||||
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||
ws.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||
if (streams.get(id) !== stream) {
|
||||
return
|
||||
}
|
||||
|
||||
streams.delete(id)
|
||||
unstall(stream)
|
||||
stream.local.destroy()
|
||||
|
||||
if (tell) {
|
||||
control({ t: 'close', id, code })
|
||||
}
|
||||
}
|
||||
|
||||
const openStream = (id: number) => {
|
||||
if (streams.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||
return control({ t: 'close', id, code: 4429 })
|
||||
}
|
||||
|
||||
let local: null | RelayLocalSocket
|
||||
|
||||
try {
|
||||
local = deps.dial()
|
||||
} catch {
|
||||
local = null
|
||||
}
|
||||
|
||||
if (!local) {
|
||||
return control({ t: 'close', id, code: 4503 })
|
||||
}
|
||||
|
||||
const stream: Stream = { local, paused: false }
|
||||
let failed = false
|
||||
|
||||
const live = () => streams.get(id) === stream
|
||||
|
||||
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||
const drain = () => {
|
||||
if (!live() || socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stream.paused = false
|
||||
local.resume()
|
||||
}
|
||||
|
||||
streams.set(id, stream)
|
||||
local.on('connect', () => {
|
||||
if (live()) {
|
||||
control({ t: 'opened', id })
|
||||
}
|
||||
})
|
||||
local.on('data', (chunk: Buffer) => {
|
||||
if (!live()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||
for (const part of splitRelayChunks(chunk)) {
|
||||
send(encodeRelayFrame(id, part))
|
||||
}
|
||||
|
||||
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
stream.paused = true
|
||||
local.pause()
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
}
|
||||
})
|
||||
local.on('drain', () => unstall(stream))
|
||||
local.on('error', () => {
|
||||
failed = true
|
||||
})
|
||||
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||
}
|
||||
|
||||
ws.on('open', () => {
|
||||
if (socket === ws) {
|
||||
attempt = 0
|
||||
setState('connected')
|
||||
watch()
|
||||
}
|
||||
})
|
||||
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||
ws.on('error', () => down(1006))
|
||||
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||
const status = Number(res?.statusCode)
|
||||
|
||||
res?.resume?.()
|
||||
req?.destroy?.()
|
||||
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||
})
|
||||
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
watch()
|
||||
|
||||
if (isBinary) {
|
||||
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||
|
||||
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = frame.readUInt32BE(0)
|
||||
const stream = streams.get(id)
|
||||
|
||||
// Late or unknown ids are ignored.
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||
|
||||
const pending = stream.local.writableLength ?? 0
|
||||
|
||||
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
endStream(id, stream, 4408, true)
|
||||
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||
stalled.add(stream)
|
||||
ws.pause()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let message: any
|
||||
|
||||
try {
|
||||
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'ping') {
|
||||
return control({ t: 'pong' })
|
||||
}
|
||||
|
||||
if (!validStreamId(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'open') {
|
||||
return openStream(message.id)
|
||||
}
|
||||
|
||||
if (message.t === 'close') {
|
||||
const stream = streams.get(message.id)
|
||||
|
||||
if (stream) {
|
||||
endStream(message.id, stream, 1000, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const api = {
|
||||
state: (): RelayState => state,
|
||||
|
||||
/** Open phone streams right now. */
|
||||
streams: (): number => streams.size,
|
||||
|
||||
/**
|
||||
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||
*/
|
||||
async ensureRegistered(): Promise<null | string> {
|
||||
const current = await register()
|
||||
|
||||
return 'error' in current ? null : current.relayId
|
||||
},
|
||||
|
||||
/**
|
||||
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
credentials = fresh
|
||||
rejected = false
|
||||
|
||||
if (wanted) {
|
||||
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||
api.stop()
|
||||
api.start()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
start() {
|
||||
if (wanted) {
|
||||
return
|
||||
}
|
||||
|
||||
wanted = true
|
||||
attempt = 0
|
||||
void connect()
|
||||
},
|
||||
|
||||
stop() {
|
||||
wanted = false
|
||||
generation++
|
||||
clearTimers()
|
||||
|
||||
const ws = socket
|
||||
|
||||
socket = null
|
||||
dropStreams()
|
||||
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
setState('off')
|
||||
}
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||
@@ -278,6 +278,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
aiturkLogin: {
|
||||
// One-click TurkServis sign-in. API keys never cross this bridge: main
|
||||
// saves the provider and hands the CLI its key (electron/aiturk-login.ts).
|
||||
start: scope => ipcRenderer.invoke('aiturk:login:start', scope),
|
||||
poll: () => ipcRenderer.invoke('aiturk:login:poll'),
|
||||
cancel: () => ipcRenderer.invoke('aiturk:login:cancel'),
|
||||
overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli')
|
||||
},
|
||||
aiturkMobile: {
|
||||
// Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs).
|
||||
open: () => ipcRenderer.invoke('aiturk:mobile:open')
|
||||
},
|
||||
mcpOauth: {
|
||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
||||
|
||||
@@ -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.12",
|
||||
"version": "1.0.0-beta.20",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
@@ -64,11 +64,13 @@ await build({
|
||||
logLevel: 'info',
|
||||
})
|
||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
||||
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cjs']) {
|
||||
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-host.css', 'mobile-preload.cjs']) {
|
||||
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||
for (const name of ['transport.mjs', 'peer-identity.mjs', 'style.css', 'qr.mjs']) {
|
||||
// The phone window only needs the shared stylesheet and the QR drawer (which
|
||||
// imports ./vendor/qrcode.mjs, staged below).
|
||||
for (const name of ['style.css', 'qr.mjs']) {
|
||||
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
|
||||
}
|
||||
// A clean `npm ci` hoists the mobile vendor packages to the workspace root, so
|
||||
@@ -84,5 +86,3 @@ function mobilePackageDir(name) {
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt'))
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
||||
|
||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const c = useAiturkCopy().custom
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const c = aiturkCopy.custom
|
||||
const loginCopy = aiturkCopy.login
|
||||
const loginLabel = loginCopy.settingsSignIn
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -129,6 +133,31 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
}
|
||||
}, [])
|
||||
|
||||
// One-click sign-in saved the TurkServis provider in the main process:
|
||||
// reload the list and tell the app its default model changed.
|
||||
async function handleAiturkConnected() {
|
||||
try {
|
||||
const data = await getCustomEndpoints()
|
||||
setEndpoints(data.endpoints)
|
||||
const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis')
|
||||
|
||||
if (current) {
|
||||
setForm(formFromEndpoint(current))
|
||||
setDiscoveredModels(current.models)
|
||||
|
||||
if (current.is_current) {
|
||||
onMainModelChanged?.(current.id, current.model)
|
||||
}
|
||||
}
|
||||
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', message: loginCopy.connected })
|
||||
} catch (err) {
|
||||
notifyError(err, c.loadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Button className="mb-3" onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
||||
<Button onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
</div>
|
||||
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
||||
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconDeviceMobile } from '@tabler/icons-react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
@@ -7,6 +8,7 @@ import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
Archive,
|
||||
@@ -73,6 +75,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const navigate = useNavigate()
|
||||
const { hash, pathname, search } = useLocation()
|
||||
|
||||
@@ -249,6 +252,21 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
// "Telefon bağlantısı" lives in its own small window (QR + code); this
|
||||
// entry only opens it. Present only where the desktop bridge offers it.
|
||||
...(window.hermesDesktop?.aiturkMobile
|
||||
? [
|
||||
{
|
||||
active: false,
|
||||
icon: IconDeviceMobile,
|
||||
id: 'aiturk-phone',
|
||||
label: aiturkCopy.mobile.nav,
|
||||
onSelect: () => {
|
||||
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
@@ -302,7 +320,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
],
|
||||
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
|
||||
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
|
||||
)
|
||||
|
||||
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AiturkCliStatus } from '@/global'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { aiturkLoginScope } from '@/lib/aiturk-provider'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing'
|
||||
|
||||
interface Waiting {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
}
|
||||
|
||||
type LoginCopy = ReturnType<typeof useAiturkCopy>
|
||||
|
||||
function errorCode(error: unknown): string {
|
||||
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
||||
return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? ''
|
||||
}
|
||||
|
||||
function errorText(code: string, copy: LoginCopy): string {
|
||||
switch (code) {
|
||||
case 'rate-limited':
|
||||
return copy.login.rateLimited
|
||||
|
||||
case 'browser-failed':
|
||||
return copy.login.browserFailed
|
||||
|
||||
case 'no-models':
|
||||
return copy.noModels
|
||||
|
||||
case 'provider-failed':
|
||||
|
||||
case 'invalid-response':
|
||||
return copy.failed
|
||||
|
||||
default:
|
||||
return copy.login.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function cliText(cli: AiturkCliStatus, copy: LoginCopy): string {
|
||||
switch (cli) {
|
||||
case 'ok':
|
||||
return copy.login.cliOk
|
||||
|
||||
case 'outdated':
|
||||
return copy.login.cliOutdated
|
||||
|
||||
case 'failed':
|
||||
|
||||
case 'unknown-account':
|
||||
return copy.login.cliFailed
|
||||
|
||||
default:
|
||||
// 'missing' stays silent; 'other-account' is asked as a question.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click TurkServis sign-in, shared by onboarding and Settings.
|
||||
*
|
||||
* The API keys never reach this component: the main process shows us only the
|
||||
* short code, then reports `connected` once it has saved the provider and
|
||||
* handed the AITURK CLI its own key.
|
||||
*/
|
||||
export function useAiturkLogin(onConnected: () => Promise<void> | void) {
|
||||
const copy = useAiturkCopy()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [waiting, setWaiting] = useState<null | Waiting>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const live = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const onConnectedRef = useRef(onConnected)
|
||||
const copyRef = useRef(copy)
|
||||
|
||||
onConnectedRef.current = onConnected
|
||||
copyRef.current = copy
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
|
||||
// Leaving mid-login: revoke the code and drop anything main still holds.
|
||||
if (live.current) {
|
||||
live.current = false
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const finish = useCallback(async (cli: AiturkCliStatus) => {
|
||||
live.current = false
|
||||
const text = cliText(cli, copyRef.current)
|
||||
|
||||
if (mounted.current) {
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setInfo(text)
|
||||
}
|
||||
|
||||
try {
|
||||
await onConnectedRef.current()
|
||||
} catch {
|
||||
// The provider is saved; a failed refresh is the caller's concern.
|
||||
}
|
||||
|
||||
// Onboarding closes this panel as soon as it is connected; keep the CLI
|
||||
// note visible as a toast in that case.
|
||||
if (!mounted.current && text) {
|
||||
notify({ kind: cli === 'ok' ? 'success' : 'info', message: text })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends
|
||||
useEffect(() => {
|
||||
if (phase !== 'waiting') {
|
||||
return
|
||||
}
|
||||
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const tick = async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
if (stopped || !bridge) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await bridge.poll()
|
||||
} catch {
|
||||
result = { state: 'pending' as const }
|
||||
}
|
||||
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'pending') {
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'connected') {
|
||||
if (result.cli === 'other-account') {
|
||||
setWaiting(null)
|
||||
setPhase('question')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await finish(result.cli)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current))
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [phase, finish])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
setError('')
|
||||
setInfo('')
|
||||
|
||||
if (!bridge) {
|
||||
setError(copyRef.current.login.unsupported)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('starting')
|
||||
|
||||
try {
|
||||
const started = await bridge.start(aiturkLoginScope())
|
||||
|
||||
if (!mounted.current) {
|
||||
void bridge.cancel().catch(() => {})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = true
|
||||
setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri })
|
||||
setPhase('waiting')
|
||||
} catch (err) {
|
||||
if (mounted.current) {
|
||||
setPhase('idle')
|
||||
setError(errorText(errorCode(err), copyRef.current))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const reopen = useCallback(() => {
|
||||
if (waiting) {
|
||||
void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {})
|
||||
}
|
||||
}, [waiting])
|
||||
|
||||
const replaceCli = useCallback(async () => {
|
||||
setPhase('replacing')
|
||||
|
||||
let cli: AiturkCliStatus = 'failed'
|
||||
|
||||
try {
|
||||
cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed'
|
||||
} catch {
|
||||
cli = 'failed'
|
||||
}
|
||||
|
||||
await finish(cli === 'other-account' ? 'failed' : cli)
|
||||
}, [finish])
|
||||
|
||||
const keepCli = useCallback(async () => {
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
await finish('missing')
|
||||
}, [finish])
|
||||
|
||||
return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli }
|
||||
}
|
||||
|
||||
interface AiturkLoginProps {
|
||||
/** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */
|
||||
onConnected: () => Promise<void> | void
|
||||
/** Idle button label; defaults to "Giriş yap". */
|
||||
label?: string
|
||||
variant?: 'default' | 'outline'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) {
|
||||
const copy = useAiturkCopy()
|
||||
const login = useAiturkLogin(onConnected)
|
||||
|
||||
if (login.phase === 'waiting' && login.waiting) {
|
||||
return (
|
||||
<div className="grid gap-3" data-testid="aiturk-login-code">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
||||
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
||||
{login.waiting.userCode}
|
||||
</output>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={login.reopen} size="sm" variant="outline">
|
||||
{copy.login.reopen}
|
||||
</Button>
|
||||
<Button onClick={login.cancel} size="sm" variant="ghost">
|
||||
{copy.login.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (login.phase === 'question' || login.phase === 'replacing') {
|
||||
const replacing = login.phase === 'replacing'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="group">
|
||||
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
||||
{replacing ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.login.cliReplace}
|
||||
</Button>
|
||||
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
||||
{copy.login.cliKeep}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const starting = login.phase === 'starting'
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
||||
{starting ? <Loader2 className="animate-spin" /> : null}
|
||||
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
||||
</Button>
|
||||
</div>
|
||||
{login.error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{login.error}
|
||||
</p>
|
||||
) : null}
|
||||
{login.info ? (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{login.info}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -17,6 +22,7 @@ function setup() {
|
||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
return { input, onConnected }
|
||||
}
|
||||
|
||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
function setupLogin(polls: unknown[]) {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const bridge = {
|
||||
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||
}
|
||||
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||
|
||||
return { bridge, onConnected, openExternal }
|
||||
}
|
||||
|
||||
const click = async (name: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
describe('TurkServis one-click sign-in', () => {
|
||||
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||
await click('Giriş yap')
|
||||
|
||||
expect(bridge.start).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
|
||||
await click('Sayfayı yeniden aç')
|
||||
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
// No key ever reaches the renderer through this flow.
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', ''],
|
||||
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||
])('connects and explains CLI result %s', async (cli, text) => {
|
||||
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||
})
|
||||
|
||||
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
|
||||
await click('Değiştir')
|
||||
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
})
|
||||
|
||||
it('leaves the other CLI account alone when the user says so', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
await click('Olduğu gibi bırak')
|
||||
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
await click('Vazgeç')
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||
await tick()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an expired code and a failed start without connecting', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||
|
||||
bridge.start.mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||
)
|
||||
await click('Giriş yap')
|
||||
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes a pending login when the panel goes away', async () => {
|
||||
const { bridge } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
cleanup()
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still connects with a pasted API key', async () => {
|
||||
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function connect() {
|
||||
if (busy || !key.trim()) return
|
||||
if (busy || !key.trim()) {return}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Discovery goes through the connected backend's existing credential
|
||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
||||
|
||||
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
||||
const model = checked.models[0]
|
||||
if (!model) throw new Error(copy.noModels)
|
||||
|
||||
if (!model) {throw new Error(copy.noModels)}
|
||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||
if (saved.ok === false) throw new Error(copy.failed)
|
||||
|
||||
if (saved.ok === false) {throw new Error(copy.failed)}
|
||||
setKey('')
|
||||
await onConnected()
|
||||
} catch (err) {
|
||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||
</div>
|
||||
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||
<label className="grid gap-2 text-sm">
|
||||
{copy.key}
|
||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
||||
placeholder={copy.key} type="password" value={key} />
|
||||
</label>
|
||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Vendored
+26
@@ -323,6 +323,18 @@ declare global {
|
||||
setDisableF12?: (blocked: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
/** One-click TurkServis sign-in (electron/aiturk-login.ts). API keys stay
|
||||
* in the main process; only the short code and a status come back. */
|
||||
aiturkLogin?: {
|
||||
start: (scope?: { connectionId?: string; profile?: string }) => Promise<AiturkLoginStart>
|
||||
poll: () => Promise<AiturkLoginPoll>
|
||||
cancel: () => Promise<{ ok: true }>
|
||||
overwriteCli: () => Promise<{ cli: AiturkCliStatus }>
|
||||
}
|
||||
/** "Telefon bağlantısı": opens the pairing window (electron/mobile-host.mjs). Windows only. */
|
||||
aiturkMobile?: {
|
||||
open: () => Promise<{ ok: true }>
|
||||
}
|
||||
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
||||
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
||||
@@ -1212,6 +1224,20 @@ export type DesktopBootstrapEvent =
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
export type AiturkCliStatus = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export interface AiturkLoginStart {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPoll =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliStatus }
|
||||
| { state: 'error'; error: string }
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -30,6 +30,28 @@ const en = {
|
||||
getKey: 'My account and API keys',
|
||||
noModels: 'No models are available for this key.',
|
||||
failed: 'Could not connect to TurkServis.',
|
||||
login: {
|
||||
signIn: 'Sign in',
|
||||
settingsSignIn: 'Sign in to AITURK',
|
||||
starting: 'Starting sign-in…',
|
||||
codeLabel: 'Sign-in code',
|
||||
confirmHint: 'Approve if the code on the page opened in your browser matches this one.',
|
||||
reopen: 'Reopen the page',
|
||||
cancel: 'Cancel',
|
||||
orPasteKey: 'or paste an API key',
|
||||
connected: 'Your TurkServis account is connected.',
|
||||
expired: 'The sign-in code expired. Try again.',
|
||||
rateLimited: 'Too many attempts. Try again in a little while.',
|
||||
unavailable: 'Could not start sign-in. Check your internet connection and try again.',
|
||||
browserFailed: 'Could not open the browser. Try again.',
|
||||
unsupported: 'One-click sign-in is not available here. Paste an API key instead.',
|
||||
cliOk: 'AITURK CLI is ready with this account too.',
|
||||
cliOutdated: 'After AITURK CLI is updated, sign in again to connect it automatically.',
|
||||
cliOtherAccount: 'AITURK CLI has a different account saved. Replace it with this account?',
|
||||
cliReplace: 'Replace',
|
||||
cliKeep: 'Leave as is',
|
||||
cliFailed: 'AITURK CLI could not be connected; run `aiturk login` in a terminal.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Custom endpoints',
|
||||
active: 'Active',
|
||||
@@ -64,6 +86,10 @@ const en = {
|
||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
||||
confirmDelete: (name: string) => `Delete ${name}?`
|
||||
},
|
||||
mobile: {
|
||||
nav: 'Phone connection',
|
||||
openFailed: 'Could not open the phone connection window.'
|
||||
},
|
||||
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
||||
downloads: 'Official downloads and updates',
|
||||
source: 'Source code and license',
|
||||
@@ -79,6 +105,28 @@ const tr: typeof en = {
|
||||
getKey: 'Hesabım ve API anahtarlarım',
|
||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
||||
login: {
|
||||
signIn: 'Giriş yap',
|
||||
settingsSignIn: 'AITURK’e giriş yap',
|
||||
starting: 'Giriş başlatılıyor…',
|
||||
codeLabel: 'Giriş kodu',
|
||||
confirmHint: 'Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.',
|
||||
reopen: 'Sayfayı yeniden aç',
|
||||
cancel: 'Vazgeç',
|
||||
orPasteKey: 'ya da API anahtarı yapıştırın',
|
||||
connected: 'TurkServis hesabınız bağlandı.',
|
||||
expired: 'Giriş kodunun süresi doldu. Yeniden deneyin.',
|
||||
rateLimited: 'Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.',
|
||||
unavailable: 'Giriş başlatılamadı. İnternet bağlantınızı kontrol edip yeniden deneyin.',
|
||||
browserFailed: 'Tarayıcı açılamadı. Yeniden deneyin.',
|
||||
unsupported: 'Tek tıkla giriş burada kullanılamıyor. API anahtarı yapıştırın.',
|
||||
cliOk: 'AITURK CLI de bu hesapla hazır.',
|
||||
cliOutdated: 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.',
|
||||
cliOtherAccount: 'AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?',
|
||||
cliReplace: 'Değiştir',
|
||||
cliKeep: 'Olduğu gibi bırak',
|
||||
cliFailed: 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Özel API bağlantıları',
|
||||
active: 'Etkin',
|
||||
@@ -113,6 +161,10 @@ const tr: typeof en = {
|
||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
||||
confirmDelete: name => `${name} silinsin mi?`
|
||||
},
|
||||
mobile: {
|
||||
nav: 'Telefon bağlantısı',
|
||||
openFailed: 'Telefon bağlantısı penceresi açılamadı.'
|
||||
},
|
||||
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
||||
downloads: 'Resmî indirmeler ve güncellemeler',
|
||||
source: 'Kaynak kodu ve lisans',
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { connectionScoped, profileScoped } from '@/api/client'
|
||||
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
||||
|
||||
/** Where the main process should save the provider after a one-click sign-in:
|
||||
* the same (connection, profile) the renderer's saveCustomEndpoint targets. */
|
||||
export function aiturkLoginScope(): { connectionId?: string; profile?: string } {
|
||||
return { ...connectionScoped(), ...profileScoped() }
|
||||
}
|
||||
|
||||
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||
return {
|
||||
id: 'turkservis',
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -53,44 +53,6 @@ npm run android:sync --workspaces=false
|
||||
üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim
|
||||
imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı.
|
||||
|
||||
Yayın adayı için sürüm ve Android sürüm kodu birlikte açıkça verilir:
|
||||
|
||||
```powershell
|
||||
.\android\gradlew.bat -p android assembleRelease '-PaiturkReleaseVersion=0.1.4-beta.1' '-PaiturkReleaseVersionCode=6'
|
||||
```
|
||||
|
||||
Bu komut yine **imzasız** APK üretir; üretim imzasını veya cihaz kabulünü atlamaz.
|
||||
İki parametreden yalnız biri, `-dev` etiketi veya 5 ve daha küçük sürüm kodu
|
||||
verilirse derleme durur. Parametresiz geliştirici derlemesi `0.1.4-dev` / 5 kalır.
|
||||
Sonraki yayınlarda sürüm kodunu son dağıtılmış APK'dan daha büyük seçin; buradaki
|
||||
alt sınır yalnız mevcut geliştirme sürümünü korur, uzak yayın envanterini sorgulamaz.
|
||||
|
||||
12 Eylül 2026: gerçek Gradle `assembleRelease` çalışması bu parametrelerle 55
|
||||
saniyede tamamlandı; `aapt2` uygulama kimliği, `0.1.4-beta.1` / 6 ve debug
|
||||
bayrağının olmadığını doğruladı. Test çıktısının SHA-256 değeri
|
||||
`a93b93b3080ec484cc998ef316528ff7b947fa366b2ff5de375386acc66fffc4`.
|
||||
`apksigner verify` imzasız çıktıyı reddetti (beklenen sonuç). Ayrı gerçek Gradle
|
||||
çalışmaları eksik sürüm kodunu, `0.1.4-dev` yayın etiketini ve sürüm kodu 5'i
|
||||
reddetti. Bu çıktı yayımlanmadı; imza ve cihaz kabulü değildir.
|
||||
|
||||
### Dağıtım APK'sının doğrulanması
|
||||
|
||||
`scripts/verify-android-release.mjs` dağıtım öncesi salt okunur APK kontrolüdür.
|
||||
`--apk`, `--java`, `--apksigner` (Android SDK içindeki apksigner.jar), `--aapt2`,
|
||||
`--certificate` (bağımsız olarak belirlenen üretim sertifikası SHA-256 değeri),
|
||||
`--version` ve `--version-code` parametrelerinin tamamını ister. Sertifika pinini
|
||||
kontrol edilen APK'dan otomatik türetmeyin. Bu araç anahtar üretmez veya APK imzalamaz.
|
||||
|
||||
Gerçek apksigner doğrulaması, v2/v3 imza, tek beklenen sertifika, uygulama kimliği
|
||||
`online.turkservis.aiturk.mobile`, tam sürüm eşleşmesi ve debug bayrağı kontrol edilir.
|
||||
Dosya doğrulama boyunca değişirse sonuç reddedilir. Debug sertifikası ve `-dev`
|
||||
sürümü dağıtım için kabul edilmez. Başarılı rapor APK hash'ini verir fakat açıkça
|
||||
`deviceAcceptanceVerified: false` içerir; fiziksel telefon kabulünün yerine geçmez.
|
||||
|
||||
12 Eylül 2026: mevcut mutual-identity geliştirici APK'sı gerçek SDK araçlarıyla
|
||||
kontrol edildi ve beklenildiği gibi `debug-signing-certificate` ile reddedildi.
|
||||
Üretim sertifikalı pozitif APK doğrulaması henüz yapılmadı.
|
||||
|
||||
## Uyumlu IDE ve bağlantı servisi
|
||||
|
||||
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
// Release identity is explicit; ordinary developer builds retain the -dev label.
|
||||
def releaseVersion = providers.gradleProperty('aiturkReleaseVersion').orNull
|
||||
def releaseCode = providers.gradleProperty('aiturkReleaseVersionCode').orNull
|
||||
if ((releaseVersion == null) != (releaseCode == null)) {
|
||||
throw new GradleException('Both aiturkReleaseVersion and aiturkReleaseVersionCode are required')
|
||||
}
|
||||
def selectedVersionCode = 5
|
||||
if (releaseVersion != null) {
|
||||
if (!(releaseVersion ==~ /\d+\.\d+\.\d+(?:-beta\.\d+)?/)) {
|
||||
throw new GradleException('Release version must be X.Y.Z or X.Y.Z-beta.N; -dev is not a release')
|
||||
}
|
||||
if (!(releaseCode ==~ /[1-9]\d{0,9}/) || releaseCode.toLong() <= 5 || releaseCode.toLong() > 2100000000L) {
|
||||
throw new GradleException('Release version code must be greater than 5 and at most 2100000000')
|
||||
}
|
||||
selectedVersionCode = releaseCode.toInteger()
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "online.turkservis.aiturk.mobile"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
@@ -24,8 +7,8 @@ android {
|
||||
applicationId "online.turkservis.aiturk.mobile"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode selectedVersionCode
|
||||
versionName releaseVersion ?: "0.1.4-dev"
|
||||
versionCode 5
|
||||
versionName "0.1.4-dev"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export function validateAndroidRelease({ signature, badging, certificate, version, versionCode }) {
|
||||
const expected = certificate.trim().replaceAll(':', '').toLowerCase()
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error('certificate-pin-invalid')
|
||||
if (!/^\d+\.\d+\.\d+(?:-beta\.\d+)?$/.test(version)) throw new Error('release-version-invalid')
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0) throw new Error('version-code-invalid')
|
||||
const signers = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-fA-F0-9]{64})\s*$/gm)]
|
||||
if (signers.length !== 1 || signers[0][1].toLowerCase() !== expected) throw new Error('signing-certificate-mismatch')
|
||||
if (/^Signer #\d+ certificate DN:.*\bCN=Android Debug\b/im.test(signature)) throw new Error('debug-signing-certificate')
|
||||
if (!/^Verified using v[23](?:\.\d+)? scheme[^\r\n]*: true\s*$/m.test(signature)) throw new Error('modern-apk-signature-required')
|
||||
const packages = [...badging.matchAll(/^package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'/gm)]
|
||||
if (packages.length !== 1 || packages[0][1] !== 'online.turkservis.aiturk.mobile') throw new Error('application-id-mismatch')
|
||||
if (packages[0][2] !== String(versionCode) || packages[0][3] !== version) throw new Error('apk-version-mismatch')
|
||||
if (/^application-debuggable\b/m.test(badging)) throw new Error('debuggable-apk')
|
||||
return { applicationId: packages[0][1], version, versionCode, certificateSha256: expected }
|
||||
}
|
||||
|
||||
async function regularFile(path) {
|
||||
let current = path
|
||||
while (true) {
|
||||
const info = await lstat(current)
|
||||
if (info.isSymbolicLink()) throw new Error('apk-reparse-path')
|
||||
if (current === path && !info.isFile()) throw new Error('apk-not-regular')
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
async function digest(path) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function tool(executable, args) {
|
||||
const env = { ...process.env }
|
||||
delete env.JAVA_TOOL_OPTIONS
|
||||
delete env._JAVA_OPTIONS
|
||||
delete env.JDK_JAVA_OPTIONS
|
||||
const result = spawnSync(executable, args, { env, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 })
|
||||
if (result.error || result.status !== 0) throw new Error('android-verification-tool-failed')
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export async function verifyAndroidRelease(options) {
|
||||
const apk = resolve(options.apk)
|
||||
await regularFile(apk)
|
||||
const before = await lstat(apk)
|
||||
const sha256 = await digest(apk)
|
||||
const signature = tool(resolve(options.java), ['-jar', resolve(options.apksigner), 'verify', '--verbose', '--print-certs', apk])
|
||||
const badging = tool(resolve(options.aapt2), ['dump', 'badging', apk])
|
||||
const identity = validateAndroidRelease({ ...options, signature, badging })
|
||||
await regularFile(apk)
|
||||
const after = await lstat(apk)
|
||||
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || await digest(apk) !== sha256) throw new Error('apk-changed-during-verification')
|
||||
return { verified: true, ...identity, size: after.size, sha256, deviceAcceptanceVerified: false }
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const { values } = parseArgs({ options: Object.fromEntries(['apk', 'java', 'apksigner', 'aapt2', 'certificate', 'version', 'version-code'].map(name => [name, { type: 'string' }])) })
|
||||
try {
|
||||
if (Object.keys(values).length !== 7) throw new Error('all-release-verification-arguments-required')
|
||||
console.log(JSON.stringify(await verifyAndroidRelease({ ...values, versionCode: Number(values['version-code']) })))
|
||||
} catch (error) {
|
||||
const code = /^[a-z][a-z0-9-]{1,95}$/.test(error.message) ? error.message : 'android-release-verification-failed'
|
||||
console.error(JSON.stringify({ verified: false, code }))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { validateAndroidRelease } from '../scripts/verify-android-release.mjs'
|
||||
|
||||
const certificate = 'a'.repeat(64)
|
||||
const fixture = {
|
||||
certificate, version: '0.1.4-beta.1', versionCode: 6,
|
||||
signature: `Verified using v2 scheme (APK Signature Scheme v2): true\nSigner #1 certificate DN: CN=AITURK Release\nSigner #1 certificate SHA-256 digest: ${certificate}\n`,
|
||||
badging: "package: name='online.turkservis.aiturk.mobile' versionCode='6' versionName='0.1.4-beta.1'\n",
|
||||
}
|
||||
|
||||
test('APK release identity requires a pinned certificate and exact version', () => {
|
||||
assert.equal(validateAndroidRelease(fixture).certificateSha256, certificate)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, certificate: 'b'.repeat(64) }), /certificate-mismatch/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, versionCode: 7 }), /version-mismatch/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, version: '0.1.4-dev' }), /release-version-invalid/)
|
||||
})
|
||||
|
||||
test('debug and ambiguous APK identities are never release-ready', () => {
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace('AITURK Release', 'Android Debug') }), /debug-signing/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging + 'application-debuggable\n' }), /debuggable-apk/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging.replace('online.turkservis.aiturk.mobile', 'other.application') }), /application-id/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature + `Signer #2 certificate SHA-256 digest: ${certificate}\n` }), /certificate-mismatch/)
|
||||
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace(': true', ': false') }), /modern-apk-signature/)
|
||||
})
|
||||
@@ -1470,6 +1470,34 @@ def _wait_for_oneshot_background_completions(cli) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _emit_approval_required(session_id) -> None:
|
||||
"""Report approvals a one-shot run could not put in front of a human.
|
||||
|
||||
One ``approval_required: <json>`` line per blocked request on stderr,
|
||||
next to ``session_id:``, so the process that launched this run can tell
|
||||
"blocked, needs a human" from "still working" without parsing prose.
|
||||
Nothing was applied for these requests; resuming the same session in an
|
||||
interactive surface is the way to get them approved.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import drain_human_approval_required
|
||||
records = drain_human_approval_required()
|
||||
except Exception:
|
||||
return
|
||||
for record in records:
|
||||
try:
|
||||
if not record.get("session_id"):
|
||||
record["session_id"] = session_id or ""
|
||||
record["resume"] = "interactive"
|
||||
print(
|
||||
"approval_required: "
|
||||
+ json.dumps(record, ensure_ascii=True, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _finalize_single_query(cli) -> None:
|
||||
"""Close one-shot CLI resources before releasing the active session lease."""
|
||||
try:
|
||||
@@ -22360,6 +22388,7 @@ def main(
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
_emit_approval_required(cli.session_id)
|
||||
|
||||
# Ensure proper exit code for automation wrappers.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# AITURK IDE beta.20 mobile adapter
|
||||
|
||||
The mobile catalog now uses the official live Hermes catalog, its cache and
|
||||
removed-entry list. Parser and nine official offline seed entries are ported
|
||||
from NousResearch/hermes-agent ad12263a5ed43109d3942a8af04d4cef210fa6ae.
|
||||
Only PyYAML import compatibility differs in the parser. The existing installer
|
||||
checks out the catalog's exact commit; client URLs cannot override catalog names.
|
||||
Version/platform incompatibility is reported before installation.
|
||||
|
||||
Mobile subagent roster/tail RPCs are connected to the existing live registry.
|
||||
Roster supports durable conversation lineage after reconnect, like upstream.
|
||||
Tail and interrupt retain exact live-session/transport authority. Transcript
|
||||
reads are bounded and confined to the profile's delegation log directory.
|
||||
The existing mobile fork fallback already uses session.branch only for -32601;
|
||||
it was not a missing feature and is unchanged.
|
||||
|
||||
No new model, team, cron job, permission, or account is installed by these changes.
|
||||
The mobile Keys/Files/Vault/Connectors exclusions remain as before; this release
|
||||
does not claim those unsupported remote screens are implemented. Android beta.5
|
||||
can use the new endpoints without a new APK. Physical-phone acceptance is separate.
|
||||
@@ -769,7 +769,10 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
|
||||
openai/codex#36793). ``process_group`` only changes which group the child
|
||||
belongs to; it does not detach the terminal or alter the fast path.
|
||||
"""
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
|
||||
env = noninteractive_git_env()
|
||||
# Read-only probes must not fetch missing trees in partial clones.
|
||||
env["GIT_NO_LAZY_FETCH"] = "1"
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=env)
|
||||
if result is None or result.returncode != 0:
|
||||
return ""
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Connect the AITURK distribution's media tools to its existing member key."""
|
||||
|
||||
|
||||
def configure_existing_media(cfg: dict) -> bool:
|
||||
"""Migrate an existing member endpoint when the upgraded backend starts."""
|
||||
providers = cfg.get("providers") or {}
|
||||
entries = providers.values() if isinstance(providers, dict) else []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and configure_media(cfg, entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def configure_media(cfg: dict, entry: dict) -> bool:
|
||||
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
|
||||
return False
|
||||
key_env = entry.get("key_env")
|
||||
if not isinstance(key_env, str) or not key_env:
|
||||
return False
|
||||
servers = cfg.get("mcp_servers")
|
||||
if servers is None:
|
||||
servers = cfg["mcp_servers"] = {}
|
||||
if not isinstance(servers, dict):
|
||||
return False
|
||||
name = "turkservis-media"
|
||||
existing = servers.get(name, {})
|
||||
if not isinstance(existing, dict):
|
||||
return False
|
||||
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
|
||||
return False
|
||||
updated = {
|
||||
**existing,
|
||||
"url": "https://ai.turkservis.online/mcp",
|
||||
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
|
||||
"timeout": 300,
|
||||
"tools": {"include": ["list_capabilities", "generate_media"]},
|
||||
}
|
||||
if updated == existing:
|
||||
return False
|
||||
servers[name] = updated
|
||||
return True
|
||||
@@ -0,0 +1,54 @@
|
||||
"""AITURK adapter for the upstream catalog and existing pinned installer.
|
||||
|
||||
Catalog parsing/cache comes from NousResearch/hermes-agent ad12263a5ed4.
|
||||
The catalog supplies the repository and immutable revision, never the client.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
import sys
|
||||
|
||||
from hermes_cli import plugin_catalog as catalog
|
||||
|
||||
|
||||
def catalog_payload():
|
||||
from hermes_cli.plugins_cmd import _discover_all_plugins, _read_install_metadata
|
||||
|
||||
installed = {row[0] for row in _discover_all_plugins()}
|
||||
metadata = _read_install_metadata()
|
||||
removed = catalog.resolved_removed_entries()
|
||||
entries = []
|
||||
for entry in catalog.load_catalog_live():
|
||||
if catalog.match_removed(entry.name, removed) or catalog.match_removed(entry.repo, removed):
|
||||
continue
|
||||
row = entry.to_dict()
|
||||
record = metadata.get(entry.name, {})
|
||||
revision = record.get("revision")
|
||||
row.update(installed=entry.name in installed, installed_sha=revision,
|
||||
update_available=bool(revision and revision != entry.sha),
|
||||
sha_short=entry.sha[:12], capability_summary=catalog.entry_capability_summary(entry))
|
||||
entries.append(row)
|
||||
return {"entries": entries, "removed": [asdict(row) for row in removed]}
|
||||
|
||||
|
||||
def install_catalog_plugin(name, *, force=False, enable=True):
|
||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||
|
||||
entry = catalog.get_live_catalog_entry(name)
|
||||
if entry is None:
|
||||
return {"ok": False, "error": "Eklenti katalogda bulunamadı."}
|
||||
removed = catalog.resolved_removed_entries()
|
||||
if catalog.match_removed(name, removed) or catalog.match_removed(entry.repo, removed):
|
||||
return {"ok": False, "error": "Bu eklenti katalogdan kaldırılmış."}
|
||||
platform = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
|
||||
supported = {"win": "windows", "win32": "windows", "darwin": "macos", "mac": "macos"}
|
||||
if entry.platforms and platform not in {supported.get(p.lower(), p.lower()) for p in entry.platforms}:
|
||||
return {"ok": False, "error": "Bu eklenti bilgisayarın işletim sistemini desteklemiyor."}
|
||||
if entry.requires_hermes:
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from hermes_cli import __version__
|
||||
try:
|
||||
compatible = __version__ in SpecifierSet(entry.requires_hermes)
|
||||
except InvalidSpecifier:
|
||||
return {"ok": False, "error": "Katalogdaki sürüm koşulu geçersiz; eklenti kurulmadı."}
|
||||
if not compatible:
|
||||
return {"ok": False, "error": f"Bu eklenti Hermes {entry.requires_hermes} gerektiriyor."}
|
||||
return dashboard_install_plugin(entry.install_identifier, force=force, enable=enable, ref=entry.sha)
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
|
||||
|
||||
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
|
||||
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
|
||||
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
|
||||
(installs of a removed name/repo are refused with the recorded reason). Full policy:
|
||||
``plugin-catalog/README.md``.
|
||||
|
||||
Live refresh: the docs build publishes the same data as ONE JSON document
|
||||
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
|
||||
an installed Hermes sees new entries and removals without updating. A fetch failure reuses the last valid
|
||||
cached copy regardless of age, then falls back to the in-tree copy when no valid cache exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_TIERS = ("official", "community")
|
||||
# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf
|
||||
# (the common case for community submissions); "general" is for plugins that fit no shelf.
|
||||
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
|
||||
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
|
||||
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
|
||||
# Offline pins expire, but cached removals remain a permanent kill list.
|
||||
LIVE_CATALOG_MAX_STALE_SECONDS = 24 * 60 * 60
|
||||
LIVE_CATALOG_FAILURE_TTL_SECONDS = 60.0
|
||||
_REQUEST_TIMEOUT = 5.0
|
||||
_MAX_LIVE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$")
|
||||
# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to
|
||||
# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha.
|
||||
IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com")
|
||||
IMAGE_HOST_SUFFIX = ".githubusercontent.com"
|
||||
|
||||
|
||||
def is_allowed_image_url(url: str) -> bool:
|
||||
from urllib.parse import urlsplit
|
||||
parts = urlsplit(url)
|
||||
host = (parts.hostname or "").lower()
|
||||
return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX))
|
||||
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemovedEntry:
|
||||
name: str
|
||||
repo: str = ""
|
||||
reason: str = ""
|
||||
date: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogCapabilities:
|
||||
provides_tools: List[str] = field(default_factory=list)
|
||||
provides_hooks: List[str] = field(default_factory=list)
|
||||
provides_middleware: List[str] = field(default_factory=list)
|
||||
requires_env: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginCatalogEntry:
|
||||
name: str # catalog key, [a-z0-9_-]{1,64}
|
||||
repo: str # https:// git URL
|
||||
sha: str # 40-hex pinned commit — mandatory
|
||||
description: str
|
||||
maintainer: str
|
||||
tier: str = "community"
|
||||
category: str = "desktop"
|
||||
requires_hermes: str = ""
|
||||
subdir: str = ""
|
||||
docs_url: str = ""
|
||||
version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed
|
||||
image: str = "" # https image URL on a GitHub host; shown on catalog cards
|
||||
screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/<name>
|
||||
readme: bool = False # docs site renders the README from the pinned commit on the entry's page
|
||||
platforms: List[str] = field(default_factory=list) # empty = all OSes
|
||||
title: str = "" # human name ("NVIDIA App"); empty = derived from ``name``
|
||||
onboarding: bool = False # curated: offered on the desktop onboarding card
|
||||
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
|
||||
known_issues: List[str] = field(default_factory=list) # #124058: informational; drivers come from plugin-catalog/*.yaml
|
||||
|
||||
@property
|
||||
def install_identifier(self) -> str:
|
||||
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
|
||||
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
caps = self.capabilities
|
||||
return {
|
||||
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
|
||||
"maintainer": self.maintainer, "tier": self.tier, "category": self.category,
|
||||
"requires_hermes": self.requires_hermes,
|
||||
"subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image,
|
||||
"screenshots": list(self.screenshots), "readme": self.readme,
|
||||
"platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding,
|
||||
"capabilities": {
|
||||
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
|
||||
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
|
||||
},
|
||||
"known_issues": list(self.known_issues),
|
||||
}
|
||||
|
||||
|
||||
def get_catalog_dir() -> Path:
|
||||
"""The ``plugin-catalog/`` directory shipped with this checkout."""
|
||||
return Path(__file__).resolve().parent.parent / "plugin-catalog"
|
||||
|
||||
|
||||
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _str_list(raw: Any) -> List[str]:
|
||||
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
|
||||
|
||||
|
||||
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
|
||||
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
|
||||
return None
|
||||
name = str(data.get("name") or "")
|
||||
repo = str(data.get("repo") or "")
|
||||
sha = str(data.get("sha") or "").strip().lower()
|
||||
tier = str(data.get("tier") or "community")
|
||||
category = str(data.get("category") or "desktop")
|
||||
problem = (
|
||||
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
|
||||
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
|
||||
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
|
||||
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
|
||||
else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})"
|
||||
if category not in CATALOG_CATEGORIES
|
||||
else None)
|
||||
if problem:
|
||||
logger.warning("Plugin catalog: %s: %s", label, problem)
|
||||
return None
|
||||
caps_raw = data.get("capabilities")
|
||||
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
|
||||
version = str(data.get("version") or "").strip()
|
||||
if version and not _VERSION_RE.match(version):
|
||||
logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version)
|
||||
version = ""
|
||||
image = str(data.get("image") or "").strip()
|
||||
if image and not is_allowed_image_url(image):
|
||||
logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image)
|
||||
image = ""
|
||||
screenshots = [s for s in _str_list(data.get("screenshots")) if is_allowed_image_url(s)]
|
||||
if len(screenshots) != len(_str_list(data.get("screenshots"))):
|
||||
logger.warning("Plugin catalog: %s: ignoring screenshots off GitHub hosts", label)
|
||||
return PluginCatalogEntry(
|
||||
name=name, repo=repo, sha=sha,
|
||||
description=str(data.get("description") or "").strip(),
|
||||
maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category,
|
||||
requires_hermes=str(data.get("requires_hermes") or "").strip(),
|
||||
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
|
||||
version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False,
|
||||
platforms=_str_list(data.get("platforms")),
|
||||
title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True,
|
||||
known_issues=_str_list(data.get("known_issues")),
|
||||
capabilities=CatalogCapabilities(
|
||||
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
|
||||
provides_middleware=_str_list(caps.get("provides_middleware")),
|
||||
requires_env=_str_list(caps.get("requires_env"))),
|
||||
)
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> Any:
|
||||
try:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
|
||||
if not isinstance(raw_list, list):
|
||||
return []
|
||||
return [
|
||||
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
|
||||
date=str(r.get("date") or ""))
|
||||
for r in raw_list if isinstance(r, dict) and r.get("name")]
|
||||
|
||||
|
||||
# ── In-tree catalog ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
|
||||
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
|
||||
Malformed entries are skipped with a warning — never raises."""
|
||||
root = catalog_dir or get_catalog_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
entries = []
|
||||
for path in sorted(root.glob("*.yaml")):
|
||||
if path.name == "removed.yaml":
|
||||
continue
|
||||
data = _read_yaml(path)
|
||||
entry = entry_from_mapping(data, str(path)) if data is not None else None
|
||||
if entry is not None:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
|
||||
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
|
||||
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
|
||||
data = _read_yaml(path) if path.is_file() else None
|
||||
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
|
||||
|
||||
|
||||
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
|
||||
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
|
||||
|
||||
|
||||
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
|
||||
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return entries
|
||||
return [e for e in entries
|
||||
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
|
||||
|
||||
|
||||
def search_catalog(query: str) -> List[PluginCatalogEntry]:
|
||||
return filter_entries(load_catalog(), query)
|
||||
|
||||
|
||||
# ── Removed / blocklist ──────────────────────────────────────────────────────
|
||||
|
||||
_SCP_URL_RE = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$") # git@host:owner/repo
|
||||
|
||||
|
||||
def _normalize_repo(url: str) -> str:
|
||||
"""Canonical ``host/path`` for a repo URL: scheme, user, ``www.``, ``.git`` and trailing slashes are
|
||||
spelling, not identity — the kill list must match ``git@github.com:Evil/Bad.git`` when it names
|
||||
``https://github.com/evil/bad``."""
|
||||
from urllib.parse import urlsplit
|
||||
text = url.strip()
|
||||
scp = _SCP_URL_RE.match(text)
|
||||
if scp:
|
||||
host, path = scp.group(1), scp.group(2)
|
||||
elif "://" in text:
|
||||
parts = urlsplit(text)
|
||||
host, path = parts.hostname or "", parts.path
|
||||
else:
|
||||
host, path = "", text
|
||||
host = host.lower().removeprefix("www.")
|
||||
path = path.strip("/").removesuffix(".git").rstrip("/").lower()
|
||||
return f"{host}/{path}" if host else path
|
||||
|
||||
|
||||
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
|
||||
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
|
||||
|
||||
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
|
||||
shipped must still block, and a stale live cache must not un-block an in-tree removal.
|
||||
"""
|
||||
if not name_or_repo:
|
||||
return None
|
||||
entries = load_removed_list(catalog_dir)
|
||||
if catalog_dir is None:
|
||||
entries = entries + live_removed_list()
|
||||
return match_removed(name_or_repo, entries)
|
||||
|
||||
|
||||
def resolved_removed_entries() -> List[RemovedEntry]:
|
||||
"""The full kill list (in-tree UNION live) in one resolution. Callers that match many candidates
|
||||
— e.g. a plugins-hub rebuild annotating every installed plugin — resolve the list once instead
|
||||
of paying a live-catalog fetch per candidate."""
|
||||
return load_removed_list() + live_removed_list()
|
||||
|
||||
|
||||
def cached_removed_entries() -> List[RemovedEntry]:
|
||||
"""In-tree list UNION the last fetched live copy, with NO network round-trip — for the load-time and
|
||||
``enable`` checks that run in every process and must never block on a dead catalog host."""
|
||||
cached = _stale_live_cache(_live_cache_path()) or {}
|
||||
return load_removed_list() + _removed_from_list(cached.get("removed"))
|
||||
|
||||
|
||||
def match_removed(
|
||||
candidate: str, entries: List[RemovedEntry]
|
||||
) -> Optional[RemovedEntry]:
|
||||
"""One candidate against a pre-resolved kill list: exact name or normalized repo URL match."""
|
||||
if not candidate:
|
||||
return None
|
||||
text = candidate.strip()
|
||||
text_repo = _normalize_repo(text)
|
||||
for entry in entries:
|
||||
if text == entry.name or (
|
||||
entry.repo and text_repo == _normalize_repo(entry.repo)
|
||||
):
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
# ── Live catalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _live_cache_path() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "plugin-catalog.json"
|
||||
|
||||
|
||||
def invalidate_live_cache_for_home(home: Path) -> None:
|
||||
"""Best-effort removal of the cached live catalog under *home* (any profile's home).
|
||||
|
||||
``hermes update`` drops it for every profile after the checkout changes: a snapshot fetched
|
||||
before the bump would otherwise out-vote the newer in-tree catalog (pins the update just
|
||||
changed, entries it just added) for the rest of :data:`LIVE_CATALOG_TTL_SECONDS` (#119340).
|
||||
The next :func:`fetch_live_catalog` re-fetches the published doc, or falls back to the
|
||||
in-tree catalog while the network is down — both newer than what was deleted. Safe when the
|
||||
cache is absent (first run, other profiles that never opened the plugins hub).
|
||||
"""
|
||||
try:
|
||||
(Path(home) / "cache" / "plugin-catalog.json").unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: could not drop the live cache under %s: %s", home, exc)
|
||||
|
||||
|
||||
# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one
|
||||
# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per
|
||||
# installed plugin), so the dashboard event loop stalled for minutes.
|
||||
_live_fetch_failed_until = 0.0
|
||||
|
||||
|
||||
def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]:
|
||||
"""A previously fetched copy still beats the in-tree one when the network is down — for
|
||||
:data:`LIVE_CATALOG_MAX_STALE_SECONDS`. Past that its pins may trail the checkout's own catalog
|
||||
(a 90-day-old cache outranked a freshly updated in-tree pin), so the entries are dropped and the
|
||||
caller falls back to in-tree; the removals are kept."""
|
||||
try:
|
||||
if not cache.is_file():
|
||||
return None
|
||||
data = json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||
if time.time() - cache.stat().st_mtime > LIVE_CATALOG_MAX_STALE_SECONDS:
|
||||
data = {**data, "entries": []}
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
|
||||
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
|
||||
back to the in-tree catalog. A failed network attempt is remembered for
|
||||
:data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one
|
||||
per call (``force`` bypasses both caches)."""
|
||||
global _live_fetch_failed_until
|
||||
cache = _live_cache_path()
|
||||
try:
|
||||
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
|
||||
return json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
|
||||
if not force and time.time() < _live_fetch_failed_until:
|
||||
return _stale_live_cache(cache)
|
||||
try:
|
||||
import httpx
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
from utils import atomic_write_text
|
||||
|
||||
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
if len(resp.content) > _MAX_LIVE_BYTES:
|
||||
raise ValueError("live catalog payload too large")
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
||||
raise ValueError("unexpected live catalog payload")
|
||||
mkdir_under_hermes_home(cache.parent)
|
||||
# Atomic: a concurrent reader (gateway, TUI, a second CLI) must never see a half-written
|
||||
# document, which would read as a fetch failure and start its own 60 s failure window.
|
||||
atomic_write_text(cache, json.dumps(data), tmp_prefix=f"{cache.name}.tmp-")
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: live fetch failed: %s", exc)
|
||||
_live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
|
||||
return _stale_live_cache(cache)
|
||||
|
||||
|
||||
_in_tree_catalog_time: Optional[float] = -1.0 # -1 = not resolved yet; None = no usable in-tree time
|
||||
|
||||
|
||||
def in_tree_catalog_time() -> Optional[float]:
|
||||
"""Commit time (epoch) of the last change to this checkout's ``plugin-catalog/``, or ``None`` when
|
||||
the install is not a git checkout (a release/pip install cannot be newer than the published doc).
|
||||
On a git checkout whose path history is unreadable without network — the treeless ``tree:0`` layout
|
||||
``hermes update`` produces — the newest checked-out catalog file's mtime stands in for it, so a
|
||||
freshly updated checkout still outranks a doc fetched before the bump. Resolved once per process."""
|
||||
global _in_tree_catalog_time
|
||||
if _in_tree_catalog_time != -1.0:
|
||||
return _in_tree_catalog_time
|
||||
root = get_catalog_dir().parent
|
||||
resolved: Optional[float] = None
|
||||
if (root / ".git").exists():
|
||||
try:
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||
|
||||
# Path history needs missing trees on tree:0 clones. The shared probe disables
|
||||
# lazy fetch only for its child and bounds timeout cleanup on every platform.
|
||||
out = bounded_git_probe(
|
||||
["git", "-C", str(root), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
|
||||
timeout=10,
|
||||
)
|
||||
resolved = float(out) if out else None
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: could not date the in-tree catalog: %s", exc)
|
||||
if resolved is None:
|
||||
# "History unreadable without network" must not collapse into "not a git checkout":
|
||||
# ``None`` feeds the frozen-copy rule that defers to the live doc, which would let a
|
||||
# cache fetched before ``hermes update`` re-pin the old sha on exactly the installs
|
||||
# that just bumped it. A treeless clone still has its checked-out files — only the
|
||||
# historical trees are missing — and their mtime dates the checkout that wrote them.
|
||||
resolved = _catalog_worktree_mtime()
|
||||
_in_tree_catalog_time = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _catalog_worktree_mtime() -> Optional[float]:
|
||||
"""Newest mtime under the checkout's ``plugin-catalog/``: a no-network freshness signal for clones
|
||||
whose path history is unreachable (treeless or offline). ``None`` when nothing is checked out."""
|
||||
try:
|
||||
times = [p.stat().st_mtime for p in get_catalog_dir().rglob("*") if p.is_file()]
|
||||
except OSError:
|
||||
return None
|
||||
return max(times) if times else None
|
||||
|
||||
|
||||
def _live_generated_time(data: Dict[str, Any]) -> Optional[float]:
|
||||
raw = data.get("generated_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool:
|
||||
"""For one entry present in both sources with a different pin: the newer catalog wins. Newer is
|
||||
decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve;
|
||||
otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release
|
||||
install's in-tree copy is frozen at release time)."""
|
||||
if tree.sha == live.sha:
|
||||
return False
|
||||
if tree_is_newer is not None:
|
||||
return tree_is_newer
|
||||
if tree.version and live.version:
|
||||
try:
|
||||
from packaging.version import Version
|
||||
return Version(tree.version) > Version(live.version)
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def load_catalog_live() -> List[PluginCatalogEntry]:
|
||||
"""Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at
|
||||
different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin,
|
||||
a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`)."""
|
||||
data = fetch_live_catalog()
|
||||
if data is None:
|
||||
return load_catalog()
|
||||
entries = [e for i, raw in enumerate(data["entries"])
|
||||
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
|
||||
if not entries:
|
||||
return load_catalog()
|
||||
in_tree = {e.name: e for e in load_catalog()}
|
||||
live_t, tree_t = _live_generated_time(data), in_tree_catalog_time()
|
||||
tree_is_newer = (tree_t > live_t) if (live_t is not None and tree_t is not None) else None
|
||||
raw_by_name = {str(raw.get("name")): raw for raw in data["entries"] if isinstance(raw, dict)}
|
||||
return [in_tree[e.name] if e.name in in_tree and _prefer_in_tree_entry(in_tree[e.name], e, tree_is_newer)
|
||||
else _with_curated_fields(e, in_tree.get(e.name), raw_by_name.get(e.name) or {})
|
||||
for e in entries]
|
||||
|
||||
|
||||
# Curated display fields a published doc older than the field does not carry. ``generated_at`` is the
|
||||
# docs build time, not the content time, so a rebuild of an older catalog outranks a checkout that added
|
||||
# the field; a doc that has the key (even ``false``) decides.
|
||||
_CURATED_FIELDS = ("onboarding", "title")
|
||||
|
||||
|
||||
def _with_curated_fields(live: PluginCatalogEntry, tree: Optional[PluginCatalogEntry], raw: Dict[str, Any]
|
||||
) -> PluginCatalogEntry:
|
||||
if tree is None or tree.sha != live.sha:
|
||||
return live
|
||||
missing = {key: getattr(tree, key) for key in _CURATED_FIELDS if key not in raw}
|
||||
return dataclasses.replace(live, **missing) if missing else live
|
||||
|
||||
|
||||
def live_removed_list() -> List[RemovedEntry]:
|
||||
data = fetch_live_catalog()
|
||||
return _removed_from_list(data.get("removed")) if data else []
|
||||
|
||||
|
||||
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
|
||||
return next((e for e in load_catalog_live() if e.name == name), None)
|
||||
|
||||
|
||||
# ── Human summaries ──────────────────────────────────────────────────────────
|
||||
|
||||
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
|
||||
"""One paragraph shown at install/enable prompts: what the user is granting."""
|
||||
caps = entry.capabilities
|
||||
parts = [f"{label} {', '.join(items)}" for label, items in (
|
||||
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
|
||||
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
|
||||
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
|
||||
if entry.description:
|
||||
bits.append(entry.description)
|
||||
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
|
||||
if entry.platforms:
|
||||
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
|
||||
if entry.requires_hermes:
|
||||
bits.append(f"Requires Hermes {entry.requires_hermes}.")
|
||||
if entry.known_issues:
|
||||
# #124058: informational — the catalog documents traps (unsupported
|
||||
# install-method/mode combinations); surface them at install prompts
|
||||
# without blocking the install.
|
||||
bits.append(f"Known issues: {'; '.join(entry.known_issues)}.")
|
||||
return " ".join(bits)
|
||||
@@ -564,6 +564,22 @@ def _install_metadata_path() -> Path:
|
||||
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
|
||||
|
||||
|
||||
def _remove_plugin_tree(path: Path) -> None:
|
||||
"""Remove one validated plugin tree, including read-only Git objects on Windows."""
|
||||
import stat
|
||||
target = path.resolve()
|
||||
if _plugins_dir().resolve() not in target.parents:
|
||||
raise ValueError("Plugin cleanup must stay strictly inside the plugin directory")
|
||||
|
||||
def retry_readonly(function, failed_path, exc_info):
|
||||
if os.name != "nt" or not isinstance(exc_info[1], PermissionError):
|
||||
raise exc_info[1]
|
||||
os.chmod(failed_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
function(failed_path)
|
||||
|
||||
shutil.rmtree(path, onerror=retry_readonly)
|
||||
|
||||
|
||||
def _read_install_metadata() -> dict[str, dict[str, object]]:
|
||||
"""Read profile-local, non-secret plugin source metadata from disk."""
|
||||
path = _install_metadata_path()
|
||||
@@ -867,7 +883,7 @@ def _install_plugin_core(
|
||||
_write_install_metadata(new_metadata)
|
||||
except Exception:
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
_remove_plugin_tree(target)
|
||||
if replaced_existing and backup.exists():
|
||||
os.replace(backup, target)
|
||||
if old_metadata:
|
||||
@@ -1208,7 +1224,7 @@ def _remove_plugin_core(target: Path) -> None:
|
||||
"""Remove one plugin and its metadata without splitting their state."""
|
||||
metadata = _read_install_metadata()
|
||||
if target.name not in metadata:
|
||||
shutil.rmtree(target)
|
||||
_remove_plugin_tree(target)
|
||||
return
|
||||
|
||||
updated = dict(metadata)
|
||||
@@ -1230,7 +1246,7 @@ def _remove_plugin_core(target: Path) -> None:
|
||||
) from restore_exc
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
shutil.rmtree(staging)
|
||||
_remove_plugin_tree(staging)
|
||||
|
||||
|
||||
def cmd_remove(name: str) -> None:
|
||||
@@ -2641,6 +2657,7 @@ def dashboard_install_plugin(
|
||||
*,
|
||||
force: bool,
|
||||
enable: bool,
|
||||
ref: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||
warnings: list[str] = []
|
||||
@@ -2657,6 +2674,7 @@ def dashboard_install_plugin(
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
ref=ref,
|
||||
)
|
||||
except PluginScanBlocked as exc:
|
||||
findings = []
|
||||
|
||||
@@ -747,9 +747,11 @@ class FontSetBody(BaseModel):
|
||||
# --- from web_server.py (originally lines 19681-19684) ---
|
||||
|
||||
class _AgentPluginInstallBody(BaseModel):
|
||||
identifier: str
|
||||
identifier: str = ""
|
||||
force: bool = False
|
||||
enable: bool = True
|
||||
catalog_name: Optional[str] = None
|
||||
ref: Optional[str] = None
|
||||
|
||||
|
||||
# --- from web_server.py (originally lines 19896-19898) ---
|
||||
@@ -763,4 +765,3 @@ class _PluginProvidersPutBody(BaseModel):
|
||||
|
||||
class _PluginVisibilityBody(BaseModel):
|
||||
hidden: bool
|
||||
|
||||
+17
-14
@@ -405,12 +405,6 @@ def _eager_reconcile_own_session_db() -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
from hermes_cli.aiturk_media import configure_existing_media
|
||||
from hermes_cli.config import read_raw_config, save_config
|
||||
with _CONFIG_MUTATION_LOCK:
|
||||
media_cfg = read_raw_config()
|
||||
if configure_existing_media(media_cfg):
|
||||
save_config(media_cfg)
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
app.state.pty_active_session_files = {} # dict[str, Path]
|
||||
@@ -8829,9 +8823,6 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
||||
providers[endpoint_id] = entry
|
||||
cfg["providers"] = providers
|
||||
|
||||
from hermes_cli.aiturk_media import configure_media
|
||||
configure_media(cfg, entry)
|
||||
|
||||
if body.make_default:
|
||||
cfg["model"] = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), endpoint_id, model, base_url
|
||||
@@ -19079,16 +19070,28 @@ async def get_plugins_hub(request: Request):
|
||||
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
|
||||
|
||||
|
||||
@app.get("/api/dashboard/plugins/catalog")
|
||||
async def get_plugins_catalog(request: Request):
|
||||
_require_token(request)
|
||||
from hermes_cli.aiturk_plugin_catalog import catalog_payload
|
||||
return await asyncio.to_thread(catalog_payload)
|
||||
|
||||
|
||||
@app.post("/api/dashboard/agent-plugins/install")
|
||||
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
||||
_require_token(request)
|
||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||
|
||||
result = dashboard_install_plugin(
|
||||
body.identifier.strip(),
|
||||
force=body.force,
|
||||
enable=body.enable,
|
||||
)
|
||||
if body.catalog_name:
|
||||
from hermes_cli.aiturk_plugin_catalog import install_catalog_plugin
|
||||
result = await asyncio.to_thread(
|
||||
install_catalog_plugin, body.catalog_name, force=body.force, enable=body.enable,
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(
|
||||
dashboard_install_plugin, body.identifier.strip(), force=body.force,
|
||||
enable=body.enable, ref=body.ref,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.12",
|
||||
"version": "1.0.0-beta.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Hermes Plugin Catalog
|
||||
|
||||
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
|
||||
(except `removed.yaml`) is one catalog entry, discoverable via
|
||||
`hermes plugins catalog` / `hermes plugins search` and installable with
|
||||
`hermes plugins install <name>`.
|
||||
|
||||
## Admission policy
|
||||
|
||||
Presence in this directory **is** the trust signal. The rules that keep it
|
||||
meaningful:
|
||||
|
||||
<!-- admission-rules:start (mirrored in website/docs/developer-guide/plugins/catalog-submission.md; tests/website/test_catalog_rules_mirror.py keeps them identical) -->
|
||||
1. **Human-merged gate.** Entries are added *only* via a PR to the
|
||||
`hermes-agent` repository, reviewed and merged by a maintainer. There is
|
||||
no self-serve registry, no automated ingestion.
|
||||
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
|
||||
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
|
||||
Installs clone the repository and check out exactly that commit.
|
||||
3. **No self-updating code.** A listed plugin must not fetch and replace
|
||||
its own files (in-app "check for updates", signed release downloaders,
|
||||
remote `plugin.js` loaders). The exact SHA pin *is* the trust model; a
|
||||
self-updater lets an installed copy move to a commit nobody reviewed.
|
||||
Updates reach users only through a SHA-bump PR here plus
|
||||
`hermes plugins update <name>`. Keep the updater in the standalone
|
||||
distribution if you want one; strip it from the catalog build.
|
||||
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
|
||||
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
|
||||
expected to look at the upstream commit range being adopted.
|
||||
5. **Owner-or-major-contributor submissions, or a maintainer-curated sweep.**
|
||||
An entry may be submitted by the plugin repository's owner or a major
|
||||
contributor to it; drive-by submissions of third-party repos are declined.
|
||||
Hermes maintainers may also add entries in batches from a reviewed sweep
|
||||
of community plugins (every pin validated and scanned at the pinned
|
||||
commit, self-updater and credential-store checks run, English-first UI).
|
||||
Authors of swept-in entries keep control: a PR from the owner adjusting
|
||||
or removing their entry is accepted on request, and SHA bumps stay
|
||||
owner-or-maintainer PRs under rule 4.
|
||||
6. **Declared capabilities must match reality.** The `capabilities:` block
|
||||
(tools, hooks, middleware, env vars) must match what the plugin actually
|
||||
registers at the pinned commit. Validation fails the entry otherwise —
|
||||
undeclared capability creep is treated as a security issue.
|
||||
7. **The install scanner runs at admission.** `hermes plugins validate` includes
|
||||
the `security scan` check: `dangerous` fails the entry; `caution` findings
|
||||
appear as warnings in the CI log and the reviewer reads them before merging.
|
||||
In exchange, installs at the pinned SHA accept `caution` without a prompt
|
||||
(`dangerous` still blocks). Review the warnings; do not merge past them.
|
||||
8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs
|
||||
in the Desktop renderer with the app's full authority (the loader isolates
|
||||
errors, not capabilities), so a listed one may only use the plugin SDK:
|
||||
no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`),
|
||||
no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk`
|
||||
/ `react` (app bundle chunks, blob or http URLs included), no script-tag
|
||||
injection, no reaching into the app's internal stores or its own markup
|
||||
(querying `data-slot` / `data-tour` / `data-sidebar` / `data-testid`
|
||||
elements from `document`, or a `document.body` MutationObserver, to restyle,
|
||||
hide, click or rewrite core UI). `hermes plugins validate` refuses these at
|
||||
admission (`desktop surface` check); a plugin that needs a capability the
|
||||
SDK lacks asks for an SDK hook instead of patching around it.
|
||||
9. **No runtime overrides of Hermes core.** A listed plugin extends Hermes only
|
||||
through public surfaces: hooks, middleware, provider profiles and the
|
||||
other `register_*` APIs, and Desktop SDK slots and routes. It must not
|
||||
replace, wrap or rebind core functions, methods, module attributes or
|
||||
private dicts in place (`AIAgent.<method> = ...`, `setattr(server, ...)`,
|
||||
`sys.modules[...]`, writes into a core module's tables). Two plugins
|
||||
patching the same seam silently break each other, and every core release
|
||||
can break both. `hermes plugins validate` refuses these at admission (`no
|
||||
core override` check). If the hook you need does not exist, open an issue
|
||||
describing it: we would rather add the seam than list a patch.
|
||||
10. **Dependency security policy is the plugin's.** Hermes's 14-day
|
||||
`exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's
|
||||
`python_dependencies` / `pyproject.toml` install under the plugin's policy
|
||||
(no quarantine, still inside Hermes's core constraints). Reviewers read the
|
||||
dependency list at the pinned SHA: bare floors (`>=X` with no upper bound)
|
||||
and floors on the newest release get a request for the oldest
|
||||
API-compatible floor plus an upper bound, and authors are strongly
|
||||
recommended to run their own release quarantine (`uv --exclude-newer` in
|
||||
their CI) — see the developer guide's *Dependency security policy*. A
|
||||
recent floor alone is not grounds to hold an entry.
|
||||
|
||||
11. **Credentials stay with their owner.** A plugin reads the credentials it is
|
||||
configured with: the env vars in `requires_env` and its own `config_schema`
|
||||
secrets. Reading another tool's login (a vendor CLI's token file, a browser
|
||||
profile) must be disclosed in the PR and is a trust-tier call for a
|
||||
maintainer. Refreshing, rotating or writing another client's OAuth tokens, or
|
||||
presenting itself as another vendor's client, is not admitted without an
|
||||
explicit maintainer ruling; a read-only build is the usual way through.
|
||||
12. **Approvals and unattended runs are respected.** A plugin never routes around
|
||||
Hermes's approval system: no auto-approving, no disabling guards, and no
|
||||
spawning Hermes or shell children that inherit YOLO or non-interactive mode
|
||||
to run commands nobody approved. Anything that waits for a person (a prompt,
|
||||
an OAuth browser flow) fails cleanly or times out under cron, the messaging
|
||||
gateway and other unattended runs instead of hanging the agent.
|
||||
13. **Risky behaviour is disclosed.** What a user would want to know before
|
||||
installing goes in the PR description and the plugin's README: network calls
|
||||
to third-party services, reads outside the plugin's own data, shell commands,
|
||||
long-running background processes, stored credentials. Telemetry and usage
|
||||
reporting are opt-in. Reviewers summarise these as disclosure lines on the
|
||||
entry PR; undisclosed behaviour found in review is a request for changes.
|
||||
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
|
||||
(the loader skips the plugin otherwise). `version` matches the pinned code,
|
||||
and Python dependencies resolve under Hermes's core constraints
|
||||
(`hermes plugins validate --install-deps` is what CI runs).
|
||||
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
|
||||
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
|
||||
are not listed under Nous branding.
|
||||
16. **One listing per plugin lineage.** A fork of a listed community plugin is
|
||||
listed only when it is materially different from the original: a different
|
||||
transport or architecture, or capability the original lacks and its author
|
||||
declined or has not answered a PR for 30 days. Improvements to a listed plugin
|
||||
go upstream as a PR to its author. A fork that renames, rebrands or adds small
|
||||
changes is declined in favour of the original. A listed fork names its origin
|
||||
in its disclosure line (`Derived from <entry>`).
|
||||
<!-- admission-rules:end -->
|
||||
|
||||
The step-by-step submission guide, with the same rules and what reviewers check,
|
||||
lives at
|
||||
[Submitting to the plugin catalog](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission).
|
||||
|
||||
## Entry schema
|
||||
|
||||
```yaml
|
||||
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
|
||||
repo: https://github.com/owner/repo # https:// only
|
||||
sha: <40-hex commit sha> # mandatory exact pin
|
||||
subdir: "" # optional; plain relative path inside the repo
|
||||
# ([A-Za-z0-9._/-]+ only: no '..', '.', empty
|
||||
# segments, absolute, or backslash forms)
|
||||
description: One-line description.
|
||||
maintainer: OwnerName
|
||||
tier: official # official | community (default community)
|
||||
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
|
||||
# (default desktop) — the shelf the entry sits on at /docs/plugins
|
||||
requires_hermes: ">=0.19" # optional
|
||||
docs_url: "" # optional
|
||||
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
|
||||
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
|
||||
# https://raw.githubusercontent.com/owner/repo/<sha>/docs/banner.png
|
||||
screenshots: [] # optional, up to 6 https images on a GitHub host; gallery on /docs/plugins/<name>
|
||||
readme: true # optional, default true; the README at the PINNED SHA renders on /docs/plugins/<name>
|
||||
platforms: [] # optional, e.g. [linux, macos]; empty = all
|
||||
title: "" # optional human name on cards ("NVIDIA App"); default = name
|
||||
onboarding: false # optional; offered on the Desktop onboarding card (official entries only)
|
||||
known_issues: [] # optional strings shown at the install prompt; informational, never blocks
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
```
|
||||
|
||||
`version`, `image`, `screenshots` and `readme` are cosmetic: none is parsed or
|
||||
used to pick what installs. The sha stays the release; bump `version` in the
|
||||
same PR that bumps `sha` so the label on the card matches the code. Images must
|
||||
live on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com`
|
||||
so the Desktop catalog and the docs site never fetch from third-party hosts;
|
||||
pin the raw URL to the entry's commit and the picture is as immutable as the
|
||||
code.
|
||||
|
||||
Every entry gets a page at `https://hermes-agent.nousresearch.com/docs/plugins/<name>`
|
||||
and every maintainer a page at `/docs/plugins/by/<maintainer>`, both generated
|
||||
from these files at docs build time. `screenshots:` fills the page's gallery;
|
||||
the build fetches the README (from `subdir` if set, else the repo root) **at the
|
||||
pinned sha** — GitHub and GitLab repos, on by default, `readme: false` opts out — and renders it
|
||||
through an allowlist (raw HTML dropped, links and images resolved against the
|
||||
pinned tree). The page therefore shows the README the reviewer read, and it
|
||||
changes only when a reviewed re-pin lands.
|
||||
|
||||
## removed.yaml — the blocklist
|
||||
|
||||
When an entry is pulled from the catalog for security or policy reasons, it
|
||||
is recorded in `removed.yaml` with a reason and date. The installer refuses
|
||||
to install anything matching a removed entry's name or repo URL, so a
|
||||
malicious plugin cannot be re-installed from a stale identifier after
|
||||
removal. A plugin that lands on the list after a user installed it stops
|
||||
updating, cannot be enabled and is refused at load time. Removals, like
|
||||
additions, land via reviewed PRs.
|
||||
|
||||
Delisting is different from removal: an entry that is unmaintained, superseded, squatting a
|
||||
name it is not affiliated with, or no longer meets a rule is deleted from the catalog (plain
|
||||
file removal, users who already installed it are unaffected). It is welcome back once the
|
||||
problem is fixed; a name squatter comes back under a distinct name. When a new rule affects
|
||||
entries that are already listed, their authors get an issue on their repository explaining
|
||||
the change and time to update before anything is delisted.
|
||||
|
||||
## Names
|
||||
|
||||
The catalog key and the manifest `name:` are what users search, install, and — for memory
|
||||
providers — put in `memory.provider`. A `memory` / `exclusive` entry must not reuse the name of
|
||||
another provider or of a well-known upstream project it is not affiliated with: two providers
|
||||
registering the same `register_memory_provider` name make `memory.provider` ambiguous
|
||||
(whichever loads last wins). Reviewers check the registered provider name, not just the file
|
||||
name, and the affiliated project gets the bare key.
|
||||
@@ -0,0 +1,23 @@
|
||||
name: blender
|
||||
repo: https://github.com/NousResearch/hermes-plugin-blender
|
||||
sha: d1929b593bddd2241a8939fb355e0a69b8e077af
|
||||
version: "0.1.0"
|
||||
description: >-
|
||||
Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3,
|
||||
with a Blender workflow skill. Requires Blender 5.1+, its separately
|
||||
installed MCP add-on, Git and uv. First startup downloads Python dependencies;
|
||||
prepare the uv environment before connecting. Disclosure: tools execute
|
||||
arbitrary Python in Blender with the user's privileges. No automatic
|
||||
application/version/liveness gate; server discovery does not prove scene readiness.
|
||||
title: Blender
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: tools
|
||||
docs_url: https://github.com/NousResearch/hermes-plugin-blender#readme
|
||||
platforms: [windows, macos, linux]
|
||||
onboarding: true
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,22 @@
|
||||
name: claude-subscription-directsdk
|
||||
repo: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk
|
||||
sha: 31b591fd04737a7807183f3d7f3d389b19f94687
|
||||
description: Experimental model provider that runs Hermes turns on a Claude Pro/Max subscription through
|
||||
the official Claude Code CLI (the Agent SDK path), so no separate API key or per-token bill. Requires
|
||||
the claude CLI installed and logged in; the plugin checks for it at load, in hermes model, in the pickers
|
||||
and before every request, and refuses cleanly with the install hint when it is missing. Disclosure — every
|
||||
turn is billed against your Claude subscription's Agent SDK allowance, and the provider inherits whatever
|
||||
account claude is logged into.
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: models
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk#readme
|
||||
version: "0.3.2"
|
||||
platforms: []
|
||||
image: https://raw.githubusercontent.com/NousResearch/hermes-plugin-claude-subscription-directsdk/31b591fd04737a7807183f3d7f3d389b19f94687/assets/card.png
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,15 @@
|
||||
name: hermes-memory-wiki
|
||||
repo: https://github.com/NousResearch/hermes-memory-wiki
|
||||
sha: 9bc3913b8474eaf4d7eec32e97af4d77957c36df
|
||||
description: 'Memory Wiki dashboard tab: browsable subject pages and daily logs derived from local session
|
||||
history, plus a read-only Persistent Memory (MEMORY.md / USER.md) audit panel.'
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
requires_hermes: ">=0.21"
|
||||
docs_url: https://github.com/NousResearch/hermes-memory-wiki#readme
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,15 @@
|
||||
name: hermes-telegram-business
|
||||
repo: https://github.com/NousResearch/hermes-telegram-business
|
||||
sha: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
|
||||
description: 'Observe-with-approval Telegram Business Mode (secretary bot): every drafted customer reply
|
||||
needs owner approval before it is sent.'
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: platform
|
||||
docs_url: https://github.com/NousResearch/hermes-telegram-business
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,23 @@
|
||||
name: homeassistant
|
||||
repo: https://github.com/NousResearch/hermes-homeassistant
|
||||
sha: ba30cb0cf86c52bdb5cde98974bc062e97966529
|
||||
description: 'Home Assistant gateway platform (state-change events in, persistent notifications and cron notify
|
||||
delivery out) plus the homeassistant toolset (ha_list_entities, ha_get_state, ha_list_services, ha_call_service).
|
||||
Formerly built into Hermes core; existing users are migrated automatically.'
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: platform
|
||||
requires_hermes: ">=0.21.5"
|
||||
version: "2.0.1"
|
||||
docs_url: https://github.com/NousResearch/hermes-homeassistant
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- ha_list_entities
|
||||
- ha_get_state
|
||||
- ha_list_services
|
||||
- ha_call_service
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- HASS_TOKEN
|
||||
@@ -0,0 +1,24 @@
|
||||
name: nvidia-app
|
||||
repo: https://github.com/NousResearch/hermes-nvidia
|
||||
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
|
||||
subdir: nvidia-app
|
||||
description: "NVIDIA App control through its local MCP server: overlay capture and state, driver status
|
||||
and release notes, game optimization, and app launch. Requires NVIDIA App 11.0 or newer on Windows;
|
||||
the install is refused when the app is absent, and the tools appear while the app's service is running.
|
||||
The plugin reads the endpoint and token NVIDIA App publishes in its own McpServer server.json on every
|
||||
connection, so a changed port or token needs no reconfiguration. Disclosure: the tools read and change
|
||||
NVIDIA App settings on this machine; nothing leaves loopback."
|
||||
title: NVIDIA App
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-app
|
||||
version: "0.1.0"
|
||||
platforms: [windows]
|
||||
onboarding: true
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,24 @@
|
||||
name: nvidia-broadcast
|
||||
repo: https://github.com/NousResearch/hermes-nvidia
|
||||
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
|
||||
subdir: nvidia-broadcast
|
||||
description: "NVIDIA Broadcast control through its local MCP gateway: camera, microphone and speaker
|
||||
effects, Studio Voice profiles, device selection, camera resolution, and effects on local media files.
|
||||
Requires NVIDIA Broadcast 2.3 or newer on Windows (2.2 and older have no gateway); the install is
|
||||
refused when the app is absent, and the tools appear while NVIDIA Broadcast is running in the user's
|
||||
desktop session. Disclosure: the tools change live audio and video effects on this machine; nothing
|
||||
leaves loopback."
|
||||
title: NVIDIA Broadcast
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-broadcast
|
||||
version: "0.1.0"
|
||||
platforms: [windows]
|
||||
onboarding: true
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,12 @@
|
||||
# Blocklist for plugins pulled from the catalog for security or policy
|
||||
# reasons. The installer refuses to install anything whose name or repo URL
|
||||
# matches an entry here (unless the caller explicitly bypasses the check).
|
||||
# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs,
|
||||
# same as additions.
|
||||
#
|
||||
# Name only. Do not set repo: the replacement entry is the same repository,
|
||||
# and a repo match would refuse that install too.
|
||||
removed:
|
||||
- name: or-picker
|
||||
reason: "Folded into openrouter-picker. Installing the provider alone left the picker broken. Install openrouter-picker."
|
||||
date: "2026-09-28"
|
||||
@@ -0,0 +1,17 @@
|
||||
name: snyk
|
||||
repo: https://github.com/NousResearch/hermes-plugin-snyk
|
||||
sha: 2a41a07f81e45125bf82a19af1b13396ace4b81f
|
||||
description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs with Snyk through its
|
||||
first-party MCP server (pinned snyk@1.1306.0 over npx stdio, CLI analytics disabled), with the
|
||||
snyk-security-scan workflow skill bundled. Portable Agent Plugins v1 package (mcp.json + skills/);
|
||||
needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).'
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: tools
|
||||
docs_url: https://github.com/NousResearch/hermes-plugin-snyk
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,16 @@
|
||||
name: touchdesigner
|
||||
repo: https://github.com/NousResearch/hermes-plugin-touchdesigner
|
||||
sha: 88a7a7884e5eda33cfe4b1dd5e69df7cb15d5898
|
||||
description: 'Drive a live TouchDesigner session through the twozero MCP server (Streamable HTTP on
|
||||
127.0.0.1:40404), with the touchdesigner-mcp workflow skill bundled. Portable Agent Plugins v1 package
|
||||
(mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.'
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
category: tools
|
||||
docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: aiturk-media
|
||||
description: "Generate images, video and speech or transcribe audio with the user's TurkServis account through the AITURK CLI (`aiturk media`). Use whenever the user asks for an image, video, voice-over or transcript."
|
||||
version: 1.0.0
|
||||
author: AITURK / TurkServis
|
||||
license: MIT
|
||||
platforms: [windows]
|
||||
prerequisites:
|
||||
commands: [aiturk]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Media, Image, Video, TTS, STT, AITURK, TurkServis]
|
||||
---
|
||||
|
||||
# AITURK media (image, video, speech, transcription)
|
||||
|
||||
AITURK IDE produces media with the AITURK CLI that is installed next to it. The
|
||||
CLI uses the same TurkServis account; usage is billed to the member's balance.
|
||||
Do not add or enable an MCP media server for this.
|
||||
|
||||
## When to use
|
||||
|
||||
The user asks for an image, a short video, spoken audio from text, or a
|
||||
transcript of an audio file.
|
||||
|
||||
## Steps
|
||||
|
||||
The launcher is `aiturk.cmd`. In PowerShell or cmd type `aiturk`; in a bash-style
|
||||
terminal (Git Bash) type `aiturk.cmd` instead, with the same arguments.
|
||||
|
||||
1. List what the account can use right now. Never rely on a remembered model list.
|
||||
|
||||
```shell
|
||||
aiturk media list --json
|
||||
```
|
||||
|
||||
Use a capability only when its `status` is `available`. `unknown` is an
|
||||
access error; `unavailable` means no usable model. Pick the model the user
|
||||
named, or a fitting `id` from the list. Never invent a model id.
|
||||
|
||||
2. Run one generation. Choose an output file name that does not exist yet.
|
||||
|
||||
```shell
|
||||
aiturk media run image --model <model-id> --text "<what the user asked for>" --output image.png
|
||||
aiturk media run video --model <model-id> --text "<prompt>" --seconds 5 --output clip.mp4
|
||||
aiturk media run tts --model <model-id> --text "<text>" --voice <voice> --output speech.mp3
|
||||
aiturk media run stt --model <model-id> --audio <audio-file> --output transcript.txt
|
||||
```
|
||||
|
||||
3. Confirm the output file exists and has the expected type, then show or link
|
||||
it to the user. Do not retry a failed or interrupted generation automatically;
|
||||
each run is billed.
|
||||
|
||||
## If the command is unavailable
|
||||
|
||||
- `aiturk` and `aiturk.cmd` both not found: try `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`.
|
||||
If that is missing too, tell the user to install the AITURK CLI from
|
||||
https://turkservis.online/indir.
|
||||
- Not signed in or an authentication error: ask the user to run `aiturk login`
|
||||
once, then `aiturk doctor`.
|
||||
- Only while the CLI cannot be used at all may an already configured TurkServis
|
||||
media connection be used instead; say that you did and why.
|
||||
|
||||
Never print API keys, tokens or authorization headers. Without a generation
|
||||
request, list only; do not start a paid trial generation.
|
||||
@@ -272,6 +272,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_REAL_HOME",
|
||||
"TERMINAL_HOME_MODE",
|
||||
"HERMES_INTERACTIVE",
|
||||
"HERMES_SINGLE_QUERY_SESSION",
|
||||
"HERMES_QUIET",
|
||||
"HERMES_TOOL_PROGRESS",
|
||||
"HERMES_TOOL_PROGRESS_MODE",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from hermes_cli import aiturk_plugin_catalog as adapter
|
||||
from hermes_cli import plugin_catalog as catalog
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
SHA = "a" * 40
|
||||
|
||||
|
||||
def entry(**extra):
|
||||
return catalog.entry_from_mapping(dict(name="demo", repo="https://github.com/owner/demo",
|
||||
sha=SHA, description="test", maintainer="owner", tier="community", **extra), "test")
|
||||
|
||||
|
||||
def test_install_uses_catalog_pin(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry())
|
||||
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||
seen = []
|
||||
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *args, **kwargs: seen.append((args, kwargs)) or {"ok": True})
|
||||
assert adapter.install_catalog_plugin("demo")["ok"]
|
||||
assert seen == [(("https://github.com/owner/demo",), {"force": False, "enable": True, "ref": SHA})]
|
||||
|
||||
|
||||
def test_missing_or_incompatible_catalog_entry_is_rejected(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: None)
|
||||
assert not adapter.install_catalog_plugin("missing")["ok"]
|
||||
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(requires_hermes=">=999.0"))
|
||||
assert not adapter.install_catalog_plugin("demo")["ok"]
|
||||
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(platforms=["nonexistent"]))
|
||||
assert not adapter.install_catalog_plugin("demo")["ok"]
|
||||
|
||||
|
||||
def test_payload_tracks_installed_revision(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "load_catalog_live", lambda: [entry()])
|
||||
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: [("demo",)])
|
||||
monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {"demo": {"revision": "b" * 40}})
|
||||
row = adapter.catalog_payload()["entries"][0]
|
||||
assert row["installed"] and row["update_available"] and row["sha"] == SHA
|
||||
|
||||
|
||||
def test_catalog_http_auth_and_install_contract(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli import web_server as web
|
||||
client = TestClient(web.app)
|
||||
assert client.get("/api/dashboard/plugins/catalog").status_code in (401, 403)
|
||||
client.headers[web._SESSION_HEADER_NAME] = web._SESSION_TOKEN
|
||||
monkeypatch.setattr(adapter, "catalog_payload", lambda: {"entries": [entry().to_dict()], "removed": []})
|
||||
response = client.get("/api/dashboard/plugins/catalog")
|
||||
assert response.status_code == 200 and response.json()["entries"][0]["sha"] == SHA
|
||||
seen = []
|
||||
monkeypatch.setattr(adapter, "install_catalog_plugin", lambda name, **kwargs: seen.append(name) or {"ok": True, "after_install_path": "private"})
|
||||
monkeypatch.setattr(web, "_get_dashboard_plugins", lambda **kwargs: [])
|
||||
monkeypatch.setattr(web, "_invalidate_plugins_hub_cache", lambda: None)
|
||||
response = client.post("/api/dashboard/agent-plugins/install", json={"catalog_name": "demo", "identifier": "https://untrusted.invalid/repo"})
|
||||
assert response.status_code == 200 and seen == ["demo"]
|
||||
assert "after_install_path" not in response.json()
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are
|
||||
skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli import plugin_catalog as pc
|
||||
|
||||
SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
|
||||
|
||||
|
||||
def _entry(name="good-plugin", **over):
|
||||
data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d",
|
||||
"maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}}
|
||||
data.update(over)
|
||||
return data
|
||||
|
||||
|
||||
def test_shipped_catalog_entries_are_all_valid_and_pinned():
|
||||
"""Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent
|
||||
shipping regression the admission CI only catches on changed files."""
|
||||
root = pc.get_catalog_dir()
|
||||
files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"]
|
||||
entries = pc.load_catalog()
|
||||
assert len(entries) == len(files) >= 1
|
||||
assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries)
|
||||
assert all(e.install_identifier.startswith(e.repo) for e in entries)
|
||||
|
||||
|
||||
def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path):
|
||||
"""``category`` is the browse shelf: absent → ``desktop``; a value outside CATALOG_CATEGORIES is an
|
||||
invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site."""
|
||||
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat")))
|
||||
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory")))
|
||||
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory")))
|
||||
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||
assert set(entries) == {"no-cat", "mem"}
|
||||
assert entries["no-cat"].category == "desktop" and entries["mem"].category == "memory"
|
||||
assert entries["mem"].to_dict()["category"] == "memory"
|
||||
|
||||
|
||||
def test_version_and_image_are_cosmetic_and_offhost_images_are_dropped(tmp_path):
|
||||
"""``version``/``image`` label the pin, they never gate it: a bad value is dropped with a warning and
|
||||
the entry survives; an image off GitHub is dropped because the Desktop browser must never fetch
|
||||
from third-party hosts."""
|
||||
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png")))
|
||||
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("offhost", version="1.4.0 beta", image="https://evil.example/x.png")))
|
||||
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||
assert set(entries) == {"labelled", "offhost"}
|
||||
assert entries["labelled"].version == "1.4.0" and entries["labelled"].image == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"
|
||||
assert entries["offhost"].version == "" and entries["offhost"].image == ""
|
||||
assert entries["labelled"].to_dict()["version"] == "1.4.0"
|
||||
|
||||
|
||||
def test_screenshots_and_readme_are_parsed_and_readme_defaults_on(tmp_path):
|
||||
shot = "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/docs/1.png"
|
||||
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("paged", screenshots=[shot, "https://evil.example/x.png"], readme=True)))
|
||||
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("plain", readme=False)))
|
||||
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("bare")))
|
||||
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||
assert entries["paged"].screenshots == [shot] and entries["paged"].readme is True
|
||||
assert entries["plain"].screenshots == [] and entries["plain"].readme is False
|
||||
assert entries["bare"].readme is True
|
||||
assert entries["paged"].to_dict()["screenshots"] == [shot] and entries["paged"].to_dict()["readme"] is True
|
||||
|
||||
|
||||
def test_invalid_entries_are_skipped_not_raised(tmp_path):
|
||||
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok", description="café")), encoding="utf-8-sig")
|
||||
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
|
||||
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y")))
|
||||
(tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name")))
|
||||
(tmp_path / "e.yaml").write_text("- not\n- a mapping\n")
|
||||
entries = pc.load_catalog(tmp_path)
|
||||
assert [e.name for e in entries] == ["ok"]
|
||||
assert entries[0].description == "café"
|
||||
|
||||
|
||||
def test_find_removed_matches_name_or_normalized_repo(tmp_path):
|
||||
(tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [
|
||||
{"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]}))
|
||||
assert pc.find_removed("evil", tmp_path).reason == "malware"
|
||||
assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None
|
||||
assert pc.find_removed("https://github.com/x/fine", tmp_path) is None
|
||||
# Scheme/host/user spellings are not identity: every git way of naming the repo is blocked.
|
||||
for spelling in ("git@github.com:x/evil.git", "ssh://git@github.com/x/evil", "http://github.com/x/evil",
|
||||
"https://www.github.com/x/evil/", "git://github.com/x/evil.git"):
|
||||
assert pc.find_removed(spelling, tmp_path) is not None, spelling
|
||||
assert pc.find_removed("git@gitlab.com:x/evil.git", tmp_path) is None # different host stays distinct
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stale", [False, True])
|
||||
def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch, stale):
|
||||
"""Offline live pins expire, but a cached removal keeps blocking installs."""
|
||||
import httpx
|
||||
|
||||
requests = []
|
||||
get = httpx.get
|
||||
|
||||
def record_get(*args, **kwargs):
|
||||
requests.append(args)
|
||||
return get(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", record_get)
|
||||
cache = tmp_path / "cache" / "plugin-catalog.json"
|
||||
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
|
||||
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable
|
||||
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
|
||||
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps({"entries": [_entry("live-only", description="café")],
|
||||
"removed": [{"name": "pulled-live", "reason": "cve"}]},
|
||||
ensure_ascii=False), encoding="utf-8-sig")
|
||||
if stale:
|
||||
os.utime(cache, (1, 1))
|
||||
requests.clear()
|
||||
# The unreachable fetch above armed the failure window; a stale cache must still
|
||||
# trigger a real retry once that window has passed.
|
||||
monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0)
|
||||
entries = pc.load_catalog_live()
|
||||
if stale:
|
||||
assert [e.name for e in entries] == [e.name for e in pc.load_catalog()]
|
||||
else:
|
||||
assert [e.name for e in entries] == ["live-only"]
|
||||
assert entries[0].description == "café"
|
||||
assert bool(requests) is stale
|
||||
assert pc.find_removed("pulled-live").reason == "cve"
|
||||
cache.write_text("{", encoding="utf-8-sig")
|
||||
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
|
||||
|
||||
|
||||
def _fresh_cache(tmp_path, monkeypatch, doc: dict):
|
||||
cache = tmp_path / "cache" / "plugin-catalog.json"
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(doc))
|
||||
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
|
||||
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # never reached / unreachable
|
||||
return cache
|
||||
|
||||
|
||||
def test_newer_in_tree_pin_outranks_a_fresh_live_cache(tmp_path, monkeypatch):
|
||||
"""Right after `hermes update` bumps an in-tree pin, a live cache fetched BEFORE the bump must not
|
||||
re-install the old sha: for the same entry the newer catalog wins (checkout catalog commit time vs
|
||||
the doc's generated_at). Live-only entries survive; an older checkout still defers to the doc."""
|
||||
old, new = SHA, "a" * 40
|
||||
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
|
||||
"entries": [_entry("shared", sha=old), _entry("live-only")], "removed": []})
|
||||
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
|
||||
bump_time = pc._live_generated_time({"generated_at": "2026-09-22T10:00:00Z"}) + 3600
|
||||
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time)
|
||||
by_name = {e.name: e for e in pc.load_catalog_live()}
|
||||
assert by_name["shared"].sha == new and "live-only" in by_name
|
||||
# Control: a checkout whose catalog predates the doc takes the live pin.
|
||||
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time - 7200)
|
||||
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
|
||||
|
||||
|
||||
def test_live_cache_write_never_truncates_the_previous_copy(tmp_path, monkeypatch):
|
||||
"""The cache is replaced atomically: a reader racing the writer (or a write that dies half-way)
|
||||
sees the whole previous document, never a truncated one."""
|
||||
cache = _fresh_cache(tmp_path, monkeypatch, {"entries": [_entry("previous")], "removed": []})
|
||||
previous = cache.read_text()
|
||||
|
||||
class _Resp:
|
||||
content = json.dumps({"entries": [_entry("fresh")], "removed": []}).encode()
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return json.loads(self.content)
|
||||
|
||||
import httpx
|
||||
import utils
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp())
|
||||
pc._live_fetch_failed_until = 0.0
|
||||
|
||||
def _dies(*_a, **_k):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(utils, "atomic_replace", _dies)
|
||||
pc.fetch_live_catalog(force=True) # the failed write is swallowed like any other fetch failure
|
||||
assert cache.read_text() == previous
|
||||
assert not list(cache.parent.glob("*.tmp*")) # no leftover temp file either
|
||||
|
||||
|
||||
def test_curated_fields_the_published_doc_lacks_come_from_the_checkout(tmp_path, monkeypatch):
|
||||
"""The docs build stamps generated_at at build time, so a doc rebuilt from an older catalog is "newer"
|
||||
than a checkout that just added `onboarding`. At the same pin, a curated field the doc does not carry
|
||||
comes from the checkout; a doc that carries it (even false) decides, and a different pin never merges."""
|
||||
live = [_entry("same"), {**_entry("says-no"), "onboarding": False}, _entry("repinned", sha="b" * 40)]
|
||||
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", "entries": live, "removed": []})
|
||||
tree = [pc.entry_from_mapping({**_entry(n), "onboarding": True, "title": "T"}, n) for n in ("same", "says-no", "repinned")]
|
||||
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: tree)
|
||||
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: 0.0) # checkout older than the doc
|
||||
by_name = {e.name: e for e in pc.load_catalog_live()}
|
||||
assert (by_name["same"].onboarding, by_name["same"].title) == (True, "T")
|
||||
assert by_name["says-no"].onboarding is False and by_name["says-no"].title == "T"
|
||||
assert by_name["repinned"].onboarding is False and by_name["repinned"].sha == "b" * 40
|
||||
|
||||
|
||||
def test_in_tree_catalog_time_does_not_lazy_fetch_on_treeless_clones(tmp_path, monkeypatch):
|
||||
"""``in_tree_catalog_time`` dates the checkout with a pathspec'd ``git log``; the pathspec makes git
|
||||
open every commit's tree. On a treeless (``tree:0``) partial clone — the layout ``hermes update``
|
||||
produces — none of those trees exist locally, so an unrestricted probe lazy-fetches each one against
|
||||
the remote: measured 40-80 s per inventory request, past the desktop's 30s RPC limit (#125683).
|
||||
The probe must scope ``GIT_NO_LAZY_FETCH`` to itself so missing objects fail fast into the
|
||||
worktree-mtime fallback — never into network work — while a full checkout keeps resolving its time.
|
||||
This clone is never checked out, so there is no mtime to fall back to either: ``None`` all the way."""
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
if shutil.which("git") is None:
|
||||
pytest.skip("git not available")
|
||||
src = tmp_path / "src"
|
||||
(src / "plugin-catalog").mkdir(parents=True)
|
||||
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
|
||||
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
|
||||
|
||||
def local_object_types(repo):
|
||||
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
|
||||
capture_output=True, text=True, env=env)
|
||||
return {line.split()[1] for line in out.stdout.splitlines() if line}
|
||||
|
||||
dst = tmp_path / "treeless"
|
||||
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
|
||||
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
|
||||
check=True, env=env)
|
||||
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
|
||||
pytest.skip("file:// transport did not honor --filter=tree:0")
|
||||
|
||||
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
|
||||
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh in this test
|
||||
assert pc.in_tree_catalog_time() is None
|
||||
assert "tree" not in local_object_types(dst) # the probe lazy-fetched nothing
|
||||
|
||||
monkeypatch.setattr(pc, "get_catalog_dir", lambda: src / "plugin-catalog")
|
||||
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
|
||||
assert pc.in_tree_catalog_time() is not None # a full checkout still resolves its commit time
|
||||
|
||||
|
||||
def test_treeless_checkout_still_outranks_a_doc_fetched_before_the_bump(tmp_path, monkeypatch):
|
||||
"""On the treeless layout the history probe fails fast, and ``None`` would feed the frozen-copy
|
||||
rule that lets the live doc win — re-pinning the old sha on exactly the checkouts ``hermes
|
||||
update`` just bumped. The checked-out files' mtime must stand in for the unreadable commit time
|
||||
(fresh checkout ⇒ in-tree pin wins; catalog files older than the doc ⇒ live pin wins), with no
|
||||
tree lazy-fetch anywhere (#125716 review)."""
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
if shutil.which("git") is None:
|
||||
pytest.skip("git not available")
|
||||
src = tmp_path / "src"
|
||||
(src / "plugin-catalog").mkdir(parents=True)
|
||||
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
|
||||
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
|
||||
(src / "plugin-catalog" / "a.yaml").write_text("name: a2\n")
|
||||
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "commit", "-qm", "bump"], cwd=src, check=True, env=env)
|
||||
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
|
||||
|
||||
def local_object_types(repo):
|
||||
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
|
||||
capture_output=True, text=True, env=env)
|
||||
return {line.split()[1] for line in out.stdout.splitlines() if line}
|
||||
|
||||
dst = tmp_path / "treeless"
|
||||
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
|
||||
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
|
||||
check=True, env=env)
|
||||
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
|
||||
pytest.skip("file:// transport did not honor --filter=tree:0")
|
||||
# Checking out only fetches HEAD's trees; the seed commit's tree stays missing, so the
|
||||
# pathspec'd probe keeps failing — while the worktree files (and their mtimes) land.
|
||||
sp.run(["git", "-C", str(dst), "checkout", "-q"], check=True, env=env)
|
||||
probe = sp.run(["git", "-C", str(dst), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
|
||||
capture_output=True, env={**env, "GIT_NO_LAZY_FETCH": "1"})
|
||||
assert probe.returncode != 0 # the probe really is blind on this layout, like a real update
|
||||
|
||||
old, new = SHA, "a" * 40
|
||||
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
|
||||
"entries": [_entry("shared", sha=old)], "removed": []})
|
||||
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
|
||||
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
|
||||
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh: probe fails, mtime stands in
|
||||
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == new
|
||||
|
||||
# Control: catalog files older than the doc mean the checkout is the stale side — live pin wins.
|
||||
for p in (dst / "plugin-catalog").rglob("*"):
|
||||
os.utime(p, (0, 0))
|
||||
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
|
||||
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
|
||||
@@ -358,7 +358,8 @@ def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_p
|
||||
target, _manifest, _name = _install_plugin_core(
|
||||
repo.as_uri(), force=False, ref=old_sha
|
||||
)
|
||||
shutil.rmtree(target)
|
||||
from hermes_cli.plugins_cmd import _remove_plugin_tree
|
||||
_remove_plugin_tree(target)
|
||||
|
||||
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
|
||||
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Protected instruction-file gate when no human can be reached.
|
||||
|
||||
A one-shot run (``chat -q`` / ``--query-file``) installs the CLI approval
|
||||
callback but never starts the prompt UI. The gate used to treat the
|
||||
registered callback as a reachable human and wait out ``approvals.timeout``
|
||||
on a panel nobody could see. These tests pin the fixed contract:
|
||||
|
||||
* no reachable human -> BLOCKED at once, nothing written, the outcome is
|
||||
recorded for the launching process;
|
||||
* the gate itself is unchanged: still asks a real human every time, still
|
||||
ignores yolo and ``approvals.single_query_mode``;
|
||||
* an approval that arrives after the calling execute_code script stopped
|
||||
waiting is not applied.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _gate_on(monkeypatch):
|
||||
import tools.approval as A
|
||||
import tools.file_tools as ft
|
||||
|
||||
monkeypatch.setattr(ft, "_protected_instruction_config", lambda: (True, []))
|
||||
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
|
||||
A.drain_human_approval_required()
|
||||
A.set_approval_reply_deadline(None)
|
||||
yield
|
||||
A.drain_human_approval_required()
|
||||
A.set_approval_reply_deadline(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approvals():
|
||||
"""CLI approval callback that records calls and gives a scripted answer."""
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
|
||||
state = {"calls": [], "answer": "deny"}
|
||||
|
||||
def cb(command, description, **kwargs):
|
||||
state["calls"].append({"command": command, **kwargs})
|
||||
answer = state["answer"]
|
||||
if callable(answer):
|
||||
return answer()
|
||||
return answer
|
||||
|
||||
set_approval_callback(cb)
|
||||
yield state
|
||||
set_approval_callback(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def one_shot(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
||||
|
||||
|
||||
def _write(path, content="injected"):
|
||||
from tools.file_tools import write_file_tool
|
||||
|
||||
return json.loads(write_file_tool(str(path), content))
|
||||
|
||||
|
||||
def _records():
|
||||
import tools.approval as A
|
||||
|
||||
return A.drain_human_approval_required()
|
||||
|
||||
|
||||
# ---- one-shot run: callback registered, nobody to answer ------------------
|
||||
|
||||
|
||||
class TestOneShotRun:
|
||||
def test_blocks_at_once_without_prompting(self, tmp_path, approvals, one_shot):
|
||||
target = tmp_path / "AGENTS.md"
|
||||
target.write_text("rules\n", encoding="utf-8")
|
||||
approvals["answer"] = "once" # would be honored if anyone were asked
|
||||
|
||||
started = time.monotonic()
|
||||
res = _write(target)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert "APPROVAL REQUIRED" in res["error"]
|
||||
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||
assert approvals["calls"] == [], "prompted on a surface nobody can see"
|
||||
assert elapsed < 5
|
||||
|
||||
def test_outcome_is_recorded_for_the_launcher(self, tmp_path, approvals, one_shot):
|
||||
import tools.approval as A
|
||||
|
||||
target = tmp_path / "CLAUDE.md"
|
||||
sid = A._approval_session_id.set("sess-123")
|
||||
tcid = A._approval_tool_call_id.set("call-9")
|
||||
try:
|
||||
_write(target)
|
||||
finally:
|
||||
A._approval_tool_call_id.reset(tcid)
|
||||
A._approval_session_id.reset(sid)
|
||||
|
||||
records = _records()
|
||||
assert len(records) == 1
|
||||
rec = records[0]
|
||||
assert rec["kind"] == "protected_instruction_file"
|
||||
assert rec["reason"] == "no_interactive_user"
|
||||
assert rec["applied"] is False
|
||||
assert rec["session_id"] == "sess-123"
|
||||
assert rec["tool_call_id"] == "call-9"
|
||||
assert rec["request_id"]
|
||||
assert any("CLAUDE.md" in t for t in rec["targets"])
|
||||
assert _records() == [], "drain must clear"
|
||||
|
||||
def test_yolo_does_not_open_the_gate(self, tmp_path, approvals, one_shot, monkeypatch):
|
||||
import tools.approval as A
|
||||
|
||||
monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", True)
|
||||
target = tmp_path / "AGENTS.md"
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
|
||||
def test_single_query_mode_approve_does_not_open_the_gate(
|
||||
self, tmp_path, approvals, one_shot, monkeypatch
|
||||
):
|
||||
import tools.approval as A
|
||||
|
||||
real_cfg_get = A.cfg_get
|
||||
|
||||
def fake_cfg_get(config, *keys, default=None):
|
||||
if keys == ("approvals", "single_query_mode"):
|
||||
return "approve"
|
||||
return real_cfg_get(config, *keys, default=default)
|
||||
|
||||
monkeypatch.setattr(A, "cfg_get", fake_cfg_get)
|
||||
target = tmp_path / "SOUL.md"
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
|
||||
def test_regular_file_still_writes(self, tmp_path, approvals, one_shot):
|
||||
target = tmp_path / "notes.md"
|
||||
res = _write(target, "hello")
|
||||
assert not res.get("error"), res
|
||||
assert target.read_text(encoding="utf-8") == "hello"
|
||||
assert _records() == []
|
||||
|
||||
def test_multi_file_patch_applies_nothing(self, tmp_path, approvals, one_shot):
|
||||
from tools.file_tools import patch_tool
|
||||
|
||||
agents = tmp_path / "AGENTS.md"
|
||||
agents.write_text("rules\n", encoding="utf-8")
|
||||
plain = tmp_path / "plain.txt"
|
||||
plain.write_text("hello\n", encoding="utf-8")
|
||||
patch = (
|
||||
"*** Begin Patch\n"
|
||||
f"*** Update File: {plain}\n"
|
||||
"@@\n"
|
||||
"-hello\n"
|
||||
"+world\n"
|
||||
f"*** Update File: {agents}\n"
|
||||
"@@\n"
|
||||
"-rules\n"
|
||||
"+injected\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
res = json.loads(patch_tool(mode="patch", patch=patch))
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert plain.read_text(encoding="utf-8") == "hello\n"
|
||||
assert agents.read_text(encoding="utf-8") == "rules\n"
|
||||
assert approvals["calls"] == []
|
||||
|
||||
def test_retry_in_same_run_blocks_again(self, tmp_path, approvals, one_shot):
|
||||
target = tmp_path / "AGENTS.md"
|
||||
_write(target)
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
assert len(_records()) == 2
|
||||
|
||||
|
||||
# ---- no channel at all ----------------------------------------------------
|
||||
|
||||
|
||||
def test_no_channel_fails_closed_and_is_recorded(tmp_path):
|
||||
target = tmp_path / "AGENTS.md"
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
records = _records()
|
||||
assert [r["reason"] for r in records] == ["no_approval_channel"]
|
||||
|
||||
|
||||
def test_undeliverable_gateway_request_is_recorded(tmp_path):
|
||||
import tools.approval as A
|
||||
|
||||
session_key = "protected-unattended-undelivered"
|
||||
token = A.set_current_session_key(session_key)
|
||||
try:
|
||||
def notify(approval_data):
|
||||
raise RuntimeError("client went away")
|
||||
|
||||
A.register_gateway_notify(session_key, notify)
|
||||
try:
|
||||
target = tmp_path / "AGENTS.md"
|
||||
res = _write(target)
|
||||
finally:
|
||||
A.unregister_gateway_notify(session_key)
|
||||
finally:
|
||||
A.reset_current_session_key(token)
|
||||
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
assert [r["reason"] for r in _records()] == ["approval_not_delivered"]
|
||||
|
||||
|
||||
# ---- a reachable human: the gate behaves exactly as before -----------------
|
||||
|
||||
|
||||
class TestReachableHuman:
|
||||
def test_allow_writes(self, tmp_path, approvals):
|
||||
target = tmp_path / "AGENTS.md"
|
||||
approvals["answer"] = "once"
|
||||
res = _write(target, "approved")
|
||||
assert not res.get("error"), res
|
||||
assert target.read_text(encoding="utf-8") == "approved"
|
||||
assert len(approvals["calls"]) == 1
|
||||
assert _records() == []
|
||||
|
||||
@pytest.mark.parametrize("answer", ["deny", "timeout"])
|
||||
def test_deny_and_silence_block(self, tmp_path, approvals, answer):
|
||||
target = tmp_path / "AGENTS.md"
|
||||
approvals["answer"] = answer
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
assert len(approvals["calls"]) == 1
|
||||
# A human was asked and did not consent: not an "unattended" outcome.
|
||||
assert _records() == []
|
||||
|
||||
def test_prompt_that_dies_blocks(self, tmp_path, approvals):
|
||||
"""Cancel / lost surface: the prompt raising is not consent."""
|
||||
def boom():
|
||||
raise RuntimeError("prompt surface lost")
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
approvals["answer"] = boom
|
||||
res = _write(target)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---- approval that arrives after the caller stopped waiting ----------------
|
||||
|
||||
|
||||
class TestLateApproval:
|
||||
def test_late_cli_approval_is_not_applied(self, tmp_path, approvals):
|
||||
import tools.approval as A
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
target.write_text("rules\n", encoding="utf-8")
|
||||
|
||||
def answer_after_deadline():
|
||||
# The human says yes, but only once the execute_code script that
|
||||
# asked for this write has already timed out.
|
||||
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||
return "once"
|
||||
|
||||
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||
approvals["answer"] = answer_after_deadline
|
||||
res = _write(target)
|
||||
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert "NOT applied" in res["error"]
|
||||
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||
|
||||
def test_late_approval_does_not_carry_to_the_next_request(self, tmp_path, approvals):
|
||||
import tools.approval as A
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||
approvals["answer"] = "once"
|
||||
assert "BLOCKED" in _write(target).get("error", "")
|
||||
assert not target.exists()
|
||||
|
||||
# A fresh call (new deadline) asks again; the old yes is not reused.
|
||||
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||
approvals["answer"] = "deny"
|
||||
assert "BLOCKED" in _write(target).get("error", "")
|
||||
assert not target.exists()
|
||||
assert len(approvals["calls"]) == 2
|
||||
|
||||
def test_in_time_approval_is_applied(self, tmp_path, approvals):
|
||||
import tools.approval as A
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||
approvals["answer"] = "once"
|
||||
res = _write(target, "approved")
|
||||
assert not res.get("error"), res
|
||||
assert target.read_text(encoding="utf-8") == "approved"
|
||||
|
||||
def test_late_gateway_approval_is_not_applied(self, tmp_path):
|
||||
import tools.approval as A
|
||||
|
||||
session_key = "protected-unattended-late"
|
||||
token = A.set_current_session_key(session_key)
|
||||
try:
|
||||
def notify(approval_data):
|
||||
A.resolve_gateway_approval(session_key, "once")
|
||||
|
||||
A.register_gateway_notify(session_key, notify)
|
||||
try:
|
||||
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||
target = tmp_path / "AGENTS.md"
|
||||
res = _write(target)
|
||||
finally:
|
||||
A.unregister_gateway_notify(session_key)
|
||||
finally:
|
||||
A.reset_current_session_key(token)
|
||||
|
||||
assert res.get("error") and "NOT applied" in res["error"]
|
||||
assert not target.exists()
|
||||
|
||||
def test_no_deadline_outside_execute_code(self):
|
||||
import tools.approval as A
|
||||
|
||||
assert A.approval_reply_window_remaining() is None
|
||||
assert A.approval_arrived_too_late() is False
|
||||
|
||||
def test_rpc_dispatch_binds_and_restores_the_deadline(self):
|
||||
import tools.approval as A
|
||||
import tools.code_execution_tool as cet
|
||||
|
||||
start = time.monotonic()
|
||||
previous = cet._bind_rpc_reply_deadline(start)
|
||||
try:
|
||||
remaining = A.approval_reply_window_remaining()
|
||||
assert remaining is not None
|
||||
assert 0 < remaining <= cet._RPC_REPLY_TIMEOUT_SECONDS
|
||||
finally:
|
||||
cet._restore_rpc_reply_deadline(previous)
|
||||
assert A.approval_reply_window_remaining() is None
|
||||
|
||||
def test_deadline_is_per_thread(self):
|
||||
import threading
|
||||
|
||||
import tools.approval as A
|
||||
|
||||
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||
seen = {}
|
||||
|
||||
def other():
|
||||
seen["late"] = A.approval_arrived_too_late()
|
||||
|
||||
worker = threading.Thread(target=other)
|
||||
worker.start()
|
||||
worker.join()
|
||||
assert seen["late"] is False
|
||||
assert A.approval_arrived_too_late() is True
|
||||
|
||||
|
||||
# ---- what the launching process sees ---------------------------------------
|
||||
|
||||
|
||||
def test_recorded_outcomes_are_bounded():
|
||||
import tools.approval as A
|
||||
|
||||
for i in range(A._HUMAN_APPROVAL_REQUIRED_MAX + 20):
|
||||
A.note_human_approval_required("protected_instruction_file", [f"f{i}"], "x")
|
||||
records = A.drain_human_approval_required()
|
||||
assert len(records) == A._HUMAN_APPROVAL_REQUIRED_MAX
|
||||
assert records[-1]["targets"] == [f"f{A._HUMAN_APPROVAL_REQUIRED_MAX + 19}"]
|
||||
|
||||
|
||||
def test_one_shot_exit_reports_approval_required_on_stderr(capsys):
|
||||
import cli as cli_module
|
||||
import tools.approval as A
|
||||
|
||||
A.note_human_approval_required(
|
||||
"protected_instruction_file", ["AGENTS.md"], "no_interactive_user"
|
||||
)
|
||||
cli_module._emit_approval_required("sess-42")
|
||||
|
||||
err = capsys.readouterr().err
|
||||
lines = [l for l in err.splitlines() if l.startswith("approval_required: ")]
|
||||
assert len(lines) == 1
|
||||
payload = json.loads(lines[0].split(": ", 1)[1])
|
||||
assert payload["session_id"] == "sess-42"
|
||||
assert payload["applied"] is False
|
||||
assert payload["reason"] == "no_interactive_user"
|
||||
assert payload["resume"] == "interactive"
|
||||
assert payload["targets"] == ["AGENTS.md"]
|
||||
|
||||
cli_module._emit_approval_required("sess-42")
|
||||
assert "approval_required" not in capsys.readouterr().err
|
||||
|
||||
|
||||
# ---- execute_code RPC: the dispatcher really binds the window ---------------
|
||||
|
||||
|
||||
def _drive_rpc_once(dispatch, stop_event=None):
|
||||
"""Send one tool call through the real RPC server loop; return its reply."""
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import tools.code_execution_tool as cet
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
stop_event = stop_event or threading.Event()
|
||||
worker = threading.Thread(
|
||||
target=cet._rpc_server_loop,
|
||||
args=(server, "task", [], [0], 5, frozenset({"patch"}), stop_event, "tok", dispatch),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
client = socket.create_connection(server.getsockname(), timeout=10)
|
||||
try:
|
||||
client.sendall(
|
||||
(json.dumps({"tool": "patch", "args": {}, "token": "tok"}) + "\n").encode()
|
||||
)
|
||||
buf = b""
|
||||
while not buf.endswith(b"\n"):
|
||||
chunk = client.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
finally:
|
||||
client.close()
|
||||
stop_event.set()
|
||||
worker.join(timeout=10)
|
||||
server.close()
|
||||
return buf.decode().strip()
|
||||
|
||||
|
||||
def test_rpc_server_loop_binds_reply_window_around_dispatch():
|
||||
import tools.approval as A
|
||||
import tools.code_execution_tool as cet
|
||||
|
||||
seen = {}
|
||||
|
||||
def dispatch(tool_name, tool_args):
|
||||
seen["remaining"] = A.approval_reply_window_remaining()
|
||||
seen["late"] = A.approval_arrived_too_late()
|
||||
return json.dumps({"ok": True})
|
||||
|
||||
reply = _drive_rpc_once(dispatch)
|
||||
assert json.loads(reply) == {"ok": True}
|
||||
assert seen["late"] is False
|
||||
assert 0 < seen["remaining"] <= cet._RPC_REPLY_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_rpc_approval_after_script_ended_is_late():
|
||||
"""Script killed by its own run timeout while the prompt is still open."""
|
||||
import threading
|
||||
|
||||
import tools.approval as A
|
||||
|
||||
stop_event = threading.Event()
|
||||
seen = {}
|
||||
|
||||
def dispatch(tool_name, tool_args):
|
||||
seen["before"] = A.approval_arrived_too_late()
|
||||
stop_event.set() # the script ended while a human was deciding
|
||||
seen["after"] = A.approval_arrived_too_late()
|
||||
return json.dumps({"ok": True})
|
||||
|
||||
_drive_rpc_once(dispatch, stop_event)
|
||||
assert seen == {"before": False, "after": True}
|
||||
|
||||
|
||||
def test_protected_write_through_rpc_is_dropped_when_script_ended(tmp_path, approvals):
|
||||
"""End to end through the RPC loop: late yes -> BLOCKED, file untouched."""
|
||||
import threading
|
||||
|
||||
from tools.file_tools import write_file_tool
|
||||
from tools.terminal_tool import _get_approval_callback, set_approval_callback
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
target.write_text("rules\n", encoding="utf-8")
|
||||
stop_event = threading.Event()
|
||||
callback = _get_approval_callback()
|
||||
|
||||
def answer_after_script_died():
|
||||
stop_event.set()
|
||||
return "once"
|
||||
|
||||
approvals["answer"] = answer_after_script_died
|
||||
|
||||
def dispatch(tool_name, tool_args):
|
||||
set_approval_callback(callback) # the RPC thread carries the cell's callback
|
||||
try:
|
||||
return write_file_tool(str(target), "injected")
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
|
||||
reply = json.loads(_drive_rpc_once(dispatch, stop_event))
|
||||
assert "NOT applied" in reply.get("error", "")
|
||||
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||
assert len(approvals["calls"]) == 1
|
||||
|
||||
|
||||
def test_retired_kernel_cell_counts_as_gone():
|
||||
import tools.approval as A
|
||||
|
||||
cell = {"active": True}
|
||||
previous = A.also_gone_when(lambda: not cell["active"])
|
||||
try:
|
||||
assert A.approval_arrived_too_late() is False
|
||||
cell["active"] = False
|
||||
assert A.approval_arrived_too_late() is True
|
||||
finally:
|
||||
A.restore_approval_reply_deadline(previous)
|
||||
assert A.approval_arrived_too_late() is False
|
||||
|
||||
|
||||
# ---- an automatic approver is not a person ----------------------------------
|
||||
|
||||
|
||||
def test_subagent_auto_approve_cannot_open_the_gate(tmp_path):
|
||||
from tools.delegate_tool import _subagent_auto_approve
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
|
||||
target = tmp_path / "AGENTS.md"
|
||||
set_approval_callback(_subagent_auto_approve)
|
||||
try:
|
||||
res = _write(target)
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
assert res.get("error") and "BLOCKED" in res["error"]
|
||||
assert not target.exists()
|
||||
assert [r["reason"] for r in _records()] == ["no_interactive_user"]
|
||||
|
||||
|
||||
def test_blocked_message_survives_braces_in_target(tmp_path):
|
||||
import tools.file_tools as ft
|
||||
|
||||
res = ft._request_protected_instruction_approval(["{be}/AGENTS.md"])
|
||||
assert res.startswith("BLOCKED") and "{be}/AGENTS.md" in res
|
||||
|
||||
|
||||
# ---- sibling gates in a one-shot run ----------------------------------------
|
||||
|
||||
|
||||
def test_computer_use_does_not_wait_in_one_shot_run(one_shot, monkeypatch):
|
||||
import tools.computer_use.tool as cu
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
|
||||
res = cu._request_approval("click", {}, session_id="unattended-test")
|
||||
assert res is not None and "one-shot run" in json.loads(res)["error"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_computer_use_still_asks_when_interactive(monkeypatch):
|
||||
import tools.computer_use.tool as cu
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
|
||||
assert cu._request_approval("click", {}, session_id="attended-test") is None
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_memory_write_prompt_unavailable_in_one_shot_run(approvals, monkeypatch):
|
||||
import tools.write_approval as wa
|
||||
|
||||
assert wa._interactive_approval_available() is True
|
||||
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
||||
assert wa._interactive_approval_available() is False
|
||||
|
||||
|
||||
def test_one_shot_marker_is_not_inherited_by_child_processes(one_shot):
|
||||
from tools.environments.local import _make_run_env
|
||||
|
||||
assert "HERMES_SINGLE_QUERY_SESSION" not in _make_run_env({})
|
||||
@@ -0,0 +1,76 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tui_gateway import mobile_subagents as mobile
|
||||
|
||||
|
||||
def test_roster_is_session_and_transport_scoped(monkeypatch):
|
||||
from tools import delegate_tool as delegate
|
||||
transport, session = object(), {}
|
||||
own = dict(subagent_id="own", owner_session_id="s", owner_transport=transport,
|
||||
owner_session_record=session, agent=object(), goal="test", started_at=1)
|
||||
other = {**own, "subagent_id": "other", "owner_transport": object()}
|
||||
reused = {**own, "subagent_id": "reused", "owner_session_record": {}}
|
||||
monkeypatch.setattr(delegate, "_active_subagents", {"own": own, "other": other, "reused": reused})
|
||||
rows = mobile.owned_records("s", transport, session)
|
||||
assert [row["subagent_id"] for row in rows] == ["own"]
|
||||
assert mobile.owned_records("s", None, session) == []
|
||||
assert mobile.owned_records("wrong", transport, session) == []
|
||||
public = mobile.roster(rows)["subagents"][0]
|
||||
assert public["elapsed_seconds"] > 0
|
||||
assert "agent" not in public and "owner_session_record" not in public
|
||||
|
||||
|
||||
def test_tail_is_bounded_and_unknown_child_is_unavailable(tmp_path, monkeypatch):
|
||||
from tools import delegation_live_log
|
||||
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path)
|
||||
path = tmp_path / "transcript.jsonl"
|
||||
path.write_bytes(b"x" * 20000 + b"end")
|
||||
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(path)))]
|
||||
result = mobile.transcript_tail(rows, "own")
|
||||
assert result["available"] and result["truncated"]
|
||||
assert result["bytes_read"] == 16384 and result["text"].endswith("end")
|
||||
assert not mobile.transcript_tail(rows, "other")["available"]
|
||||
path.unlink()
|
||||
assert not mobile.transcript_tail(rows, "own")["available"]
|
||||
|
||||
|
||||
def test_tail_rejects_outside_log_root(tmp_path, monkeypatch):
|
||||
from tools import delegation_live_log
|
||||
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path / "logs")
|
||||
secret = tmp_path / "private.txt"
|
||||
secret.write_text("not a transcript")
|
||||
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(secret)))]
|
||||
assert not mobile.transcript_tail(rows, "own")["available"]
|
||||
|
||||
|
||||
def test_roster_survives_resume_but_tail_keeps_exact_authority(monkeypatch):
|
||||
from tools import delegate_tool as delegate
|
||||
parent = SimpleNamespace(session_id="conversation")
|
||||
record = dict(subagent_id="child", owner_session_id="old-runtime",
|
||||
owner_agent_session_id="conversation", agent=None)
|
||||
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
|
||||
session = {"agent": parent}
|
||||
assert mobile.owned_records("new-runtime", object(), session) == []
|
||||
assert len(mobile.owned_records("new-runtime", object(), session, include_lineage=True)) == 1
|
||||
|
||||
|
||||
def test_registered_rpcs_reject_wrong_transport(monkeypatch):
|
||||
from tui_gateway import server
|
||||
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: ({"session_key": "s"}, None))
|
||||
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (None, None))
|
||||
for name in ("subagent.list", "subagent.tail", "subagent.interrupt"):
|
||||
response = server._methods[name](1, {"session_id": "s", "subagent_id": "child"})
|
||||
assert response["error"]["code"] == 4001
|
||||
|
||||
|
||||
def test_registered_list_returns_owned_children(monkeypatch):
|
||||
from tui_gateway import server
|
||||
from tools import delegate_tool as delegate
|
||||
transport, session = object(), {"session_key": "s"}
|
||||
record = dict(subagent_id="child", owner_session_id="s", owner_transport=transport,
|
||||
owner_session_record=session, goal="test")
|
||||
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
|
||||
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None))
|
||||
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (transport, session))
|
||||
response = server._methods["subagent.list"](1, {"session_id": "s"})
|
||||
assert response["result"]["subagents"][0]["subagent_id"] == "child"
|
||||
@@ -1,35 +1,52 @@
|
||||
from hermes_cli.aiturk_media import configure_media, configure_existing_media
|
||||
"""AITURK IDE produces media through the AITURK CLI, never by adding an MCP server."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL = ROOT / "skills" / "media" / "aiturk-media" / "SKILL.md"
|
||||
|
||||
|
||||
def test_existing_member_profile_migrates_once_without_changing_provider():
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}
|
||||
cfg = {"providers": {"turkservis": dict(entry)}, "model": {"default": "kept"}}
|
||||
assert configure_existing_media(cfg)
|
||||
assert cfg["providers"]["turkservis"] == entry
|
||||
assert cfg["model"] == {"default": "kept"}
|
||||
assert not configure_existing_media(cfg)
|
||||
def test_backend_never_registers_a_turkservis_media_mcp_server():
|
||||
assert importlib.util.find_spec("hermes_cli.aiturk_media") is None
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
for needle in ("aiturk_media", "turkservis-media", "generate_media", "ai.turkservis.online/mcp"):
|
||||
assert needle not in source
|
||||
|
||||
|
||||
def test_member_key_is_referenced_without_copying_or_overwriting_other_servers():
|
||||
cfg = {"mcp_servers": {"other": {"url": "https://example.org/mcp"}}}
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "HERMES_CUSTOM_TURKSERVIS_API_KEY"}
|
||||
assert configure_media(cfg, entry)
|
||||
assert cfg["mcp_servers"]["other"] == {"url": "https://example.org/mcp"}
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"] == "Bearer ${HERMES_CUSTOM_TURKSERVIS_API_KEY}"
|
||||
assert not configure_media(cfg, entry)
|
||||
entry["key_env"] = "ROTATED_MEMBER_KEY"
|
||||
assert configure_media(cfg, entry)
|
||||
assert "ROTATED_MEMBER_KEY" in cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"]
|
||||
def test_no_backend_module_writes_the_turkservis_mcp_endpoint_into_config():
|
||||
offenders = [
|
||||
path.relative_to(ROOT).as_posix()
|
||||
for package in ("hermes_cli", "agent", "tools", "gateway", "tui_gateway")
|
||||
for path in (ROOT / package).rglob("*.py")
|
||||
if "ai.turkservis.online/mcp" in path.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_other_provider_and_missing_key_do_not_enable_media():
|
||||
for entry in ({"base_url": "https://example.com/v1", "key_env": "SECRET"}, {"base_url": "https://ai.turkservis.online/v1"}):
|
||||
cfg = {}
|
||||
assert not configure_media(cfg, entry)
|
||||
assert cfg == {}
|
||||
def test_existing_user_mcp_entries_are_left_exactly_as_they_are():
|
||||
# Startup used to rewrite config.yaml; with the writer gone, a saved config
|
||||
# round-trips untouched (including a disabled turkservis-media entry).
|
||||
before = {
|
||||
"providers": {"turkservis": {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}},
|
||||
"mcp_servers": {
|
||||
"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False},
|
||||
"other": {"url": "https://example.org/mcp"},
|
||||
},
|
||||
}
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
lifespan = source[source.index("async def _lifespan("):]
|
||||
lifespan = lifespan[:lifespan.index("yield")]
|
||||
assert "save_config" not in lifespan and "mcp_servers" not in lifespan
|
||||
assert yaml.safe_load(yaml.safe_dump(before)) == before
|
||||
|
||||
|
||||
def test_explicitly_disabled_connection_stays_disabled():
|
||||
cfg = {"mcp_servers": {"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False}}}
|
||||
configure_media(cfg, {"base_url": "https://ai.turkservis.online/v1", "key_env": "KEY"})
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["enabled"] is False
|
||||
def test_bundled_skill_teaches_aiturk_media():
|
||||
text = SKILL.read_text(encoding="utf-8")
|
||||
meta = yaml.safe_load(text.split("---")[1])
|
||||
assert meta["name"] == "aiturk-media"
|
||||
for phrase in ("aiturk media list --json", "aiturk media run image", "aiturk media run video",
|
||||
"aiturk media run tts", "aiturk media run stt", "aiturk login"):
|
||||
assert phrase in text
|
||||
assert "Do not add or enable an MCP media server" in text
|
||||
@@ -78,6 +78,115 @@ _hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.Con
|
||||
)
|
||||
|
||||
|
||||
# Absolute ``time.monotonic()`` instant after which whoever issued the
|
||||
# current tool call has stopped waiting for its result. Set by the
|
||||
# execute_code RPC dispatchers, whose sandbox client gives up after a fixed
|
||||
# reply timeout. An approval that resolves after this instant must NOT be
|
||||
# acted on: the caller already reported the call as failed, so applying it
|
||||
# would be a write the agent believes never happened (and may redo).
|
||||
# The reply timeout is not the only way a caller goes away: the script can
|
||||
# hit its own run timeout or be interrupted first. ``gone`` is a probe the
|
||||
# dispatcher supplies for that ("has my script/cell already ended?").
|
||||
# Thread-local on purpose: the dispatcher and the tool handler it calls run
|
||||
# on the same thread, whatever context the handler is re-bound to.
|
||||
_approval_reply_deadline = threading.local()
|
||||
|
||||
|
||||
def set_approval_reply_deadline(deadline: Optional[float], gone=None):
|
||||
"""Bind the caller's reply window for this thread; return the prior binding.
|
||||
|
||||
``deadline`` is monotonic seconds (None = unbounded); ``gone`` is an
|
||||
optional zero-argument callable that turns true once the caller has
|
||||
ended. Pass the returned value to :func:`restore_approval_reply_deadline`.
|
||||
"""
|
||||
previous = (
|
||||
getattr(_approval_reply_deadline, "value", None),
|
||||
getattr(_approval_reply_deadline, "gone", None),
|
||||
)
|
||||
_approval_reply_deadline.value = deadline
|
||||
_approval_reply_deadline.gone = gone
|
||||
return previous
|
||||
|
||||
|
||||
def restore_approval_reply_deadline(previous) -> None:
|
||||
"""Undo :func:`set_approval_reply_deadline` / :func:`also_gone_when`."""
|
||||
deadline, gone = previous if previous else (None, None)
|
||||
_approval_reply_deadline.value = deadline
|
||||
_approval_reply_deadline.gone = gone
|
||||
|
||||
|
||||
def also_gone_when(probe):
|
||||
"""Add another "caller has ended" probe, keeping the current window."""
|
||||
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||
current = getattr(_approval_reply_deadline, "gone", None)
|
||||
|
||||
def gone() -> bool:
|
||||
return bool(probe()) or bool(current and current())
|
||||
|
||||
return set_approval_reply_deadline(deadline, gone)
|
||||
|
||||
|
||||
def approval_reply_window_remaining() -> Optional[float]:
|
||||
"""Seconds left before the caller stops waiting, or None when unbounded."""
|
||||
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||
if deadline is None:
|
||||
return None
|
||||
return deadline - time.monotonic()
|
||||
|
||||
|
||||
def approval_arrived_too_late() -> bool:
|
||||
"""True when the caller of the current tool call has stopped waiting."""
|
||||
gone = getattr(_approval_reply_deadline, "gone", None)
|
||||
if gone is not None:
|
||||
try:
|
||||
if gone():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
remaining = approval_reply_window_remaining()
|
||||
return remaining is not None and remaining <= 0
|
||||
|
||||
|
||||
# Approvals that could not be put in front of a human because the run has
|
||||
# no interactive channel (single-query ``chat -Q`` stage runs, scripts).
|
||||
# The single-query CLI reports them on stderr as ``approval_required:``
|
||||
# lines so the coordinating process gets a machine-readable outcome
|
||||
# instead of inferring one from prose. Bounded: long-lived processes that
|
||||
# never drain it must not grow without limit.
|
||||
_human_approval_required: list[dict] = []
|
||||
_HUMAN_APPROVAL_REQUIRED_MAX = 50
|
||||
_human_approval_required_lock = threading.Lock()
|
||||
|
||||
|
||||
def note_human_approval_required(kind: str, targets: list[str], reason: str) -> dict:
|
||||
"""Record an approval the run could not present to a human.
|
||||
|
||||
Captures the active session/tool-call identity so the coordinator can
|
||||
tie the outcome to the exact job and resume the same session.
|
||||
"""
|
||||
record = {
|
||||
"kind": kind,
|
||||
"targets": list(dict.fromkeys(str(t) for t in targets)),
|
||||
"reason": reason,
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"tool_call_id": _approval_tool_call_id.get() or "",
|
||||
"session_id": _approval_session_id.get() or os.getenv("HERMES_SESSION_ID", ""),
|
||||
"applied": False,
|
||||
}
|
||||
with _human_approval_required_lock:
|
||||
_human_approval_required.append(record)
|
||||
del _human_approval_required[:-_HUMAN_APPROVAL_REQUIRED_MAX]
|
||||
return dict(record)
|
||||
|
||||
|
||||
def drain_human_approval_required() -> list[dict]:
|
||||
"""Return and clear the recorded approval-required outcomes."""
|
||||
with _human_approval_required_lock:
|
||||
records = list(_human_approval_required)
|
||||
_human_approval_required.clear()
|
||||
return records
|
||||
|
||||
|
||||
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
|
||||
"""Bind interactive mode for the current context (thread or asyncio task).
|
||||
|
||||
|
||||
@@ -716,6 +716,36 @@ def _call(tool_name, args):
|
||||
# RPC server (runs in a thread inside the parent process)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# How long the generated sandbox client waits for one tool-call reply
|
||||
# (``_sock.settimeout(300)`` / the file transport's 300s poll deadline).
|
||||
_RPC_REPLY_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def _bind_rpc_reply_deadline(call_start: float, stop_event=None):
|
||||
"""Tell the approval gates when this call's requester stops waiting.
|
||||
|
||||
The requester is gone at the reply timeout, or earlier when the script
|
||||
itself ended (run timeout, interrupt): ``stop_event`` is set then.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import set_approval_reply_deadline
|
||||
gone = stop_event.is_set if stop_event is not None else None
|
||||
return (True, set_approval_reply_deadline(
|
||||
call_start + _RPC_REPLY_TIMEOUT_SECONDS, gone))
|
||||
except Exception:
|
||||
return (False, None)
|
||||
|
||||
|
||||
def _restore_rpc_reply_deadline(previous) -> None:
|
||||
if not previous or not previous[0]:
|
||||
return
|
||||
try:
|
||||
from tools.approval import restore_approval_reply_deadline
|
||||
restore_approval_reply_deadline(previous[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Terminal parameters that must not be used from ephemeral sandbox scripts
|
||||
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
|
||||
|
||||
@@ -826,12 +856,18 @@ def _rpc_server_loop(
|
||||
# Dispatch through the standard tool handler.
|
||||
# Suppress stdout/stderr from internal tool handlers so
|
||||
# their status prints don't leak into the CLI spinner.
|
||||
# The sandbox client stops waiting for this reply after
|
||||
# _RPC_REPLY_TIMEOUT_SECONDS; an approval granted later
|
||||
# must not apply a write the script already saw fail.
|
||||
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
result = dispatch(tool_name, tool_args)
|
||||
except Exception as exc:
|
||||
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
|
||||
result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
|
||||
tool_args.pop(param, None)
|
||||
|
||||
# Dispatch through the standard tool handler
|
||||
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
tool_result = handle_function_call(
|
||||
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
|
||||
logger.error("Tool call failed in remote sandbox: %s",
|
||||
exc, exc_info=True)
|
||||
tool_result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
|
||||
@@ -322,9 +322,25 @@ class CellAuthority:
|
||||
set_sudo(self._sudo_cb)
|
||||
except Exception:
|
||||
previous = None
|
||||
# A cell that settled (timeout, interrupt, error) no longer waits for
|
||||
# this call: an approval granted afterwards must not be applied.
|
||||
reply_window = None
|
||||
try:
|
||||
from tools.approval import also_gone_when
|
||||
|
||||
reply_window = (also_gone_when(lambda: not self.active),)
|
||||
except Exception:
|
||||
reply_window = None
|
||||
try:
|
||||
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
|
||||
finally:
|
||||
if reply_window is not None:
|
||||
try:
|
||||
from tools.approval import restore_approval_reply_deadline
|
||||
|
||||
restore_approval_reply_deadline(reply_window[0])
|
||||
except Exception:
|
||||
pass
|
||||
if previous is not None and self._callback_setters is not None:
|
||||
set_approval, set_sudo = self._callback_setters
|
||||
try:
|
||||
|
||||
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
|
||||
# No CLI approval wired — default allow. Gateway approval is handled
|
||||
# one layer out via the normal tool-approval infra.
|
||||
return None
|
||||
try:
|
||||
from tools.approval import _is_single_query_approval_context
|
||||
unattended = _is_single_query_approval_context()
|
||||
except Exception:
|
||||
unattended = False
|
||||
if unattended:
|
||||
# One-shot run: the prompt callback is installed but its panel is
|
||||
# never shown, so asking would only wait out approvals.timeout.
|
||||
return json.dumps({
|
||||
"error": (
|
||||
"approval required but this is a one-shot run with no "
|
||||
"interactive user to approve it. Silence is not consent; "
|
||||
"do not retry without the user."
|
||||
),
|
||||
"action": action,
|
||||
})
|
||||
summary = _summarize_action(action, args)
|
||||
try:
|
||||
verdict = cb(action, args, summary)
|
||||
|
||||
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
|
||||
return "once"
|
||||
|
||||
|
||||
# Not a person: gates that require real human consent every time (protected
|
||||
# instruction files) must not accept this callback's answer.
|
||||
_subagent_auto_approve.unattended = True
|
||||
|
||||
|
||||
def _get_subagent_approval_callback():
|
||||
"""Return the callback to install into subagent worker threads.
|
||||
|
||||
|
||||
@@ -1546,6 +1546,10 @@ def _make_run_env(env: dict) -> dict:
|
||||
run_env[real_key] = v
|
||||
elif _is_hermes_internal_secret(k):
|
||||
continue
|
||||
elif k == "HERMES_SINGLE_QUERY_SESSION":
|
||||
# Describes this process only. A child (an interactive hermes
|
||||
# started from a one-shot run's terminal) sets its own.
|
||||
continue
|
||||
else:
|
||||
first_party = _is_terminal_first_party_env(k)
|
||||
passthrough = _is_passthrough(k)
|
||||
|
||||
+79
-14
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
|
||||
"required (not bypassed by auto-approve)."
|
||||
)
|
||||
display = f"<write to {targets}>"
|
||||
blocked = (
|
||||
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
|
||||
"{why} The user has NOT consented to this write. Do NOT retry it or "
|
||||
"attempt the same edit via another path (terminal, execute_code, "
|
||||
"etc.)."
|
||||
)
|
||||
class _Blocked:
|
||||
# Not str.format: a target path may itself contain braces.
|
||||
@staticmethod
|
||||
def format(why: str) -> str:
|
||||
return (
|
||||
"BLOCKED: write to protected agent-instruction file(s) "
|
||||
f"({targets}) {why} The user has NOT consented to this "
|
||||
"write. Do NOT retry it or attempt the same edit via "
|
||||
"another path (terminal, execute_code, etc.)."
|
||||
)
|
||||
|
||||
blocked = _Blocked
|
||||
|
||||
try:
|
||||
import tools.approval as _approval
|
||||
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
|
||||
return blocked.format(why="requires approval but the approval "
|
||||
"subsystem is unavailable.")
|
||||
|
||||
def _needs_human(reason: str, why: str) -> str:
|
||||
# No human could be shown this request. Record it so a one-shot
|
||||
# run can hand its coordinator a machine-readable outcome, and tell
|
||||
# the agent where a human CAN approve it instead of waiting on a
|
||||
# prompt nobody sees.
|
||||
try:
|
||||
_approval.note_human_approval_required(
|
||||
"protected_instruction_file", list(reasons), reason)
|
||||
except Exception:
|
||||
pass
|
||||
return blocked.format(
|
||||
why=why + " APPROVAL REQUIRED: a human must approve this from "
|
||||
"an interactive session (resume this same session in "
|
||||
"the desktop app or an interactive terminal and "
|
||||
"request the write again there).")
|
||||
|
||||
def _too_late() -> str | None:
|
||||
# The approval came back after whoever issued this call stopped
|
||||
# waiting (execute_code RPC reply timeout). The caller already saw
|
||||
# a failure, so applying the write now would be an edit the agent
|
||||
# believes never happened.
|
||||
try:
|
||||
late = _approval.approval_arrived_too_late()
|
||||
except Exception:
|
||||
late = False
|
||||
if not late:
|
||||
return None
|
||||
logger.warning(
|
||||
"Approval for protected instruction file(s) %s arrived after "
|
||||
"the caller stopped waiting; write not applied.", targets)
|
||||
return blocked.format(
|
||||
why="was approved only after the calling script had stopped "
|
||||
"waiting for the result, so the write was NOT applied. "
|
||||
"The approval is spent; a new request needs a new approval.")
|
||||
|
||||
# Gateway surface: block on the button round-trip when a notify callback
|
||||
# is registered for this session (Telegram/Discord/Slack). One-operation
|
||||
# only — no session/permanent buttons are offered.
|
||||
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
|
||||
session_key, notify_cb, approval_data, surface="gateway",
|
||||
)
|
||||
if decision.get("notify_failed"):
|
||||
return blocked.format(
|
||||
why="requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
return _needs_human(
|
||||
"approval_not_delivered",
|
||||
"requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
choice = decision.get("choice")
|
||||
if decision.get("resolved") and choice in {"once", "session", "always"}:
|
||||
# One-operation grant regardless of the tapped scope — nothing
|
||||
# is persisted for this gate.
|
||||
return None
|
||||
return _too_late()
|
||||
if not decision.get("resolved"):
|
||||
return blocked.format(
|
||||
why="approval prompt timed out without a user response. "
|
||||
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
|
||||
callback = None
|
||||
|
||||
if callback is not None:
|
||||
# A one-shot run (``chat -q`` / ``--query-file``) installs the
|
||||
# prompt callback but never starts the prompt UI: the panel would
|
||||
# be painted nowhere and the wait would last the whole
|
||||
# approvals.timeout with nobody able to answer. A registered
|
||||
# callback is not a reachable human. Fail closed NOW;
|
||||
# approvals.single_query_mode does not apply to this gate.
|
||||
try:
|
||||
unattended = _approval._is_single_query_approval_context()
|
||||
except Exception:
|
||||
unattended = False
|
||||
if unattended:
|
||||
return _needs_human(
|
||||
"no_interactive_user",
|
||||
"requires approval but this is a one-shot run with no "
|
||||
"interactive user to approve it.")
|
||||
if getattr(callback, "unattended", False):
|
||||
# An auto-answering callback (subagent auto-approve) is not a
|
||||
# person; this gate takes consent from a human only.
|
||||
return _needs_human(
|
||||
"no_interactive_user",
|
||||
"requires approval but only an automatic approver is "
|
||||
"attached to this run, not a person.")
|
||||
choice = _approval.prompt_dangerous_approval(
|
||||
display, description,
|
||||
allow_permanent=False,
|
||||
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
|
||||
)
|
||||
if choice in {"once", "session", "always"}:
|
||||
# One-operation grant; never persisted (see docstring).
|
||||
return None
|
||||
return _too_late()
|
||||
if choice == "timeout":
|
||||
return blocked.format(
|
||||
why="approval prompt timed out without a user response. "
|
||||
@@ -938,9 +1002,10 @@ def _request_protected_instruction_approval(
|
||||
|
||||
# No human channel at all (script, cron, background thread): fail
|
||||
# closed. Auto-approving here would recreate the persistence vector.
|
||||
return blocked.format(
|
||||
why="requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
return _needs_human(
|
||||
"no_approval_channel",
|
||||
"requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
|
||||
|
||||
def _check_protected_instruction_write(paths: list[str],
|
||||
|
||||
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
|
||||
review affordance (``/memory pending``) instead.
|
||||
* Scripts, cron, and background threads — no user present.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import _is_single_query_approval_context
|
||||
if _is_single_query_approval_context():
|
||||
# One-shot run: the callback exists but its panel is never
|
||||
# shown, so an inline prompt would wait with nobody to answer.
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
return _get_approval_callback() is not None
|
||||
|
||||
@@ -3572,14 +3572,53 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"paused": set_spawn_paused(paused)})
|
||||
|
||||
|
||||
@method("subagent.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
from tui_gateway.mobile_subagents import owned_records, roster
|
||||
_, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
session_id = str(params.get("session_id") or "").strip()
|
||||
transport, session = _current_session_steer_authority(session_id)
|
||||
if transport is None or session is None:
|
||||
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||
return _ok(rid, roster(owned_records(session_id, transport, session, include_lineage=True)))
|
||||
|
||||
|
||||
@method("subagent.tail")
|
||||
def _(rid, params: dict) -> dict:
|
||||
from tui_gateway.mobile_subagents import owned_records, transcript_tail
|
||||
_, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
subagent_id = str(params.get("subagent_id") or "").strip()
|
||||
if not subagent_id:
|
||||
return _err(rid, 4000, "subagent_id required")
|
||||
session_id = str(params.get("session_id") or "").strip()
|
||||
transport, session = _current_session_steer_authority(session_id)
|
||||
if transport is None or session is None:
|
||||
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||
with _session_home_scope(session):
|
||||
result = transcript_tail(owned_records(session_id, transport, session), subagent_id)
|
||||
return _ok(rid, result)
|
||||
|
||||
|
||||
@method("subagent.interrupt")
|
||||
def _(rid, params: dict) -> dict:
|
||||
from tools.delegate_tool import interrupt_subagent
|
||||
from tui_gateway.mobile_subagents import owned_records
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
|
||||
subagent_id = str(params.get("subagent_id") or "").strip()
|
||||
if not subagent_id:
|
||||
return _err(rid, 4000, "subagent_id required")
|
||||
ok = interrupt_subagent(subagent_id)
|
||||
session_id = str(params.get("session_id") or "").strip()
|
||||
transport, session = _current_session_steer_authority(session_id)
|
||||
if transport is None or session is None:
|
||||
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||
record = next((r for r in owned_records(session_id, transport, session)
|
||||
if r.get("subagent_id") == subagent_id), None)
|
||||
child = record.get("agent") if record else None
|
||||
ok = bool(request_hard_interrupt(child, "Interrupted via mobile")) if child is not None else False
|
||||
return _ok(rid, {"found": ok, "subagent_id": subagent_id})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Read-only mobile subagent views, scoped to the invoking live session."""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def owned_records(session_id, transport, session, *, include_lineage=False):
|
||||
from tools.delegate_tool import _active_subagents, _active_subagents_lock, _owns_subagent_record
|
||||
if transport is None or session is None:
|
||||
return []
|
||||
with _active_subagents_lock:
|
||||
records = [dict(row) for row in _active_subagents.values()]
|
||||
return [row for row in records
|
||||
if (row.get("owner_session_id") == session_id
|
||||
and row.get("owner_transport") is transport
|
||||
and row.get("owner_session_record") is session)
|
||||
or (include_lineage and _owns_subagent_record(row, session.get("agent")))]
|
||||
|
||||
|
||||
def roster(records):
|
||||
fields = ("subagent_id", "parent_id", "depth", "goal", "delegation_id", "model",
|
||||
"started_at", "status", "tool_count", "last_tool", "accepting_steer")
|
||||
rows = []
|
||||
for record in records:
|
||||
row = {key: record[key] for key in fields if key in record}
|
||||
started = record.get("started_at")
|
||||
if isinstance(started, (int, float)):
|
||||
row["elapsed_seconds"] = max(0, time.time() - started)
|
||||
rows.append(row)
|
||||
return {"subagents": rows, "delegations": []}
|
||||
|
||||
|
||||
def transcript_tail(records, subagent_id):
|
||||
result = {"subagent_id": subagent_id, "available": False, "text": "", "bytes_read": 0, "truncated": False}
|
||||
record = next((r for r in records if r.get("subagent_id") == subagent_id), None)
|
||||
if record is None:
|
||||
return result
|
||||
path = getattr(record.get("agent"), "_live_transcript_path", None)
|
||||
if not isinstance(path, (str, Path)):
|
||||
return result
|
||||
try:
|
||||
# Only the registry's own file is read; no client-supplied path is accepted.
|
||||
from tools.delegation_live_log import live_transcript_root
|
||||
path = Path(path)
|
||||
root = live_transcript_root().resolve()
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||
return result
|
||||
with path.open("rb") as stream:
|
||||
size = stream.seek(0, 2)
|
||||
stream.seek(max(0, size - 16384))
|
||||
raw = stream.read(16384)
|
||||
result.update(available=True, text=raw.decode("utf-8", errors="replace"),
|
||||
bytes_read=len(raw), truncated=size > len(raw))
|
||||
except OSError:
|
||||
pass # A just-completed child's temporary transcript may already be gone.
|
||||
return result
|
||||
Reference in new issue
Block a user