Compare commits

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

Kart: turkservis-20261007-1421-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
2026-10-07 18:12:14 +03:00
yilsemandClaude Opus 5.5 7e4cc0915c AITURK IDE 1.0.0-beta.18: gorunmeyen onay beklemesi yerine aninda "onay gerekli"
A one-shot run (hermes chat --query-file ... -Q, stdin closed, no window)
still installs the prompt_toolkit approval callback but never starts the
prompt UI. The protected instruction-file gate (AGENTS.md / CLAUDE.md /
SOUL.md ...) took the registered callback for a reachable human and waited
out approvals.timeout on a panel painted nowhere. With the profile value of
300000 s the stage job hung for good.

- Gate: in a one-shot run, or when only an automatic approver
  (subagent_auto_approve) is attached, return BLOCKED / APPROVAL REQUIRED
  immediately instead of prompting. Human consent is still required every
  time; yolo and approvals.single_query_mode still do not open this gate.
- One-shot exit prints an "approval_required: {json}" line on stderr
  (session_id, tool_call_id, request_id, targets, reason, applied=false)
  so the launcher can tell "blocked, needs a human" from "still working".
- execute_code: an approval that arrives after the calling script stopped
  waiting (300 s reply timeout, script run timeout, retired kernel cell)
  is not applied.
- Same invisible wait removed from the computer_use and memory-write
  prompts in a one-shot run. The one-shot marker is no longer inherited by
  child processes.
- BLOCKED text no longer breaks on a target path containing braces.

Long-running tools are untouched: no general tool timeout was shortened.

Kart: turkservis-20261007-1421-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
2026-10-07 17:17:05 +03:00
yilsemandClaude Opus 5.5 58dbb4523b AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir
Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive
baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes
dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu).

- publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429
  yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin
  yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter).
- Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar.
- Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi
  yeniden denenmez).
- Gunluge yalniz "pair offer published / failed after N tries" duser; kod,
  lookup ve jeton yazilmaz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 14:27:59 +03:00
yilsemandClaude Opus 5.5 2ed9a6b2de feat(aiturk): phone link works from any network through an end-to-end encrypted tunnel (1.0.0-beta.16)
The phone link no longer depends on the local network. The IDE dials out to
turkservis.online and bridges tunnel streams to its own TLS proxy on
loopback; the phone runs TLS to the pinned IDE certificate inside the
tunnel, so the relay only carries ciphertext. The proxy binds to 127.0.0.1
by default and enabling the feature never asks for a firewall rule; direct
same-network access is an explicit option. The window shows the real relay,
listener and firewall state, and pairing no longer needs local addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:22:51 +03:00
yilsemandClaude Opus 5.5 03d3803dfc feat(aiturk): phone link for AITURK Mobil (1.0.0-beta.15)
Settings gets a Phone link window: when enabled, the IDE main process runs a
small TLS proxy on the local network (self-signed certificate, pinned by its
fingerprint) in front of the loopback backend and pairs a phone by QR code
or an 8-character code. Each phone gets its own revocable token; the backend
session token never leaves the computer. The root page, terminal sockets and
secret or settings-changing endpoints are not reachable from a phone. The
old WebRTC pairing window is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:02:02 +03:00
yilsemandClaude Opus 5.5 25d8c18c10 feat(aiturk): one-click sign-in for AITURK IDE (1.0.0-beta.14)
The onboarding and provider settings get a Sign in button: the IDE shows a
short code, opens the turkservis.online approval page, and after approval
saves the TurkServis provider itself and hands a separate member key to the
AITURK CLI over stdin (aiturk login --key-stdin --keep-other-account).
Keys never reach the renderer, logs, process arguments or URLs. Pasting an
API key remains as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:13:23 +03:00
yilsemandClaude Opus 5.5 fe0a45a84f fix(aiturk): tell the agent the CLI launcher name for bash-style terminals
Acceptance on Windows: the IDE agent's Git Bash terminal does not resolve the `aiturk` launcher (a .cmd file) and needed several attempts before it called it by path. The media skill now says to use `aiturk.cmd` there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwqJMAuhMBMWvsvNLFGb9
2026-10-03 11:30:47 +03:00
yilsemandClaude Opus 5.5 67a99be224 feat(aiturk): produce media through the AITURK CLI, stop adding the media MCP server (beta.13)
Owner decision (3 Oct 2026): where the AITURK CLI is available, TurkServis
models are reached through it, not MCP. The backend no longer writes a
`turkservis-media` MCP server into config.yaml on startup or when a member
endpoint is saved. Existing entries, including disabled ones, are left exactly
as they are. A bundled `aiturk-media` skill teaches the agent to list and run
media with `aiturk media`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwqJMAuhMBMWvsvNLFGb9
2026-10-03 11:03:47 +03:00
77 changed files with 12169 additions and 559 deletions

No files matched your search

+110
View File
@@ -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.
+656
View File
@@ -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()
})
})
+668
View File
@@ -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)
})
+30 -2
View File
@@ -1,3 +1,5 @@
import path from 'node:path'
interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
}
interface FirstRunSetupGateOptions {
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
hideChoice?: () => void
log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
stuckAfterMs?: number
}
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
export function createFirstRunSetupGate({
isRuntimeAvailable,
hideChoice,
log,
onStuck,
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
let localBootstrapConfirmed = false
let waiter: {
activeRoot?: string
promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void
} | null = null
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
return 'continue-local' as const
}
// Recheck synchronously before registering the waiter: another profile
// may have completed installation before this stale descriptor arrived.
if (backend && isRuntimeAvailable?.(backend)) {
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
return 'runtime-ready' as const
}
if (waiter) {
return waiter.promise
}
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
resolveWaiter = resolve
})
waiter = { promise, resolve: resolveWaiter }
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
return promise
}
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
settleWaiter('continue-local')
}
// A sibling profile may finish installing the SAME shared runtime while the
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
// this as permission for another installation, or reuse the sibling's
// profile/backend descriptor.
const runtimeAvailable = (root: string) => {
const canonical = (value: string) => {
const absolute = path.resolve(value)
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
}
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
settleWaiter('runtime-ready')
hideChoice?.()
return true
}
const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
isLocalBootstrapConfirmed,
resetForRepair,
resetForRetry,
runtimeAvailable,
shouldGate,
wait
}
+180 -13
View File
@@ -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')
})
+18
View File
@@ -0,0 +1,18 @@
/** Mobile routing is explicit and cannot start a first-run installer. */
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
isRemote,
runtimeNeedsInstall,
ensureBackend
}: {
isRemote: (profile: string) => boolean
runtimeNeedsInstall: (profile: string) => boolean
ensureBackend: (profile: string) => Promise<Connection>
}) {
return async (profile: string) => {
if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready')
const connection = await ensureBackend(profile)
if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
return { baseUrl: connection.baseUrl, token: connection.token }
}
}
+166 -91
View File
@@ -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()
+27
View File
@@ -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 }
+33 -23
View File
@@ -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>
+41 -146
View File
@@ -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')
})
})
+159
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */
import https from 'node:https'
import crypto from 'node:crypto'
import type { PeerCertificate } from 'node:tls'
import { expect, it } from 'vitest'
import { WebSocket } from 'ws'
import { createSelfSignedCertificate } from './mobile-lan-cert'
import { createLanProxy } from './mobile-lan-proxy'
it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
'real backend: pinned TLS, screens, cron lifecycle and chat socket',
async () => {
const port = Number(process.env.AITURK_TEST_BACKEND_PORT)
expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true)
const baseUrl = `http://127.0.0.1:${port}`
const root = await (await fetch(baseUrl)).text()
const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1]
expect(Boolean(session)).toBe(true)
const identity = createSelfSignedCertificate()
const checkServerIdentity = (_host: string, cert: PeerCertificate) =>
crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint
? undefined
: new Error('Certificate pin mismatch')
const proxy = createLanProxy({
tls: identity,
version: 'acceptance',
pcName: 'acceptance',
resolveBackend: async profile => {
if (profile !== 'default') throw new Error('Acceptance may only use the default profile')
return { baseUrl, token: session! }
}
})
const proxyPort = await proxy.listen(0, '127.0.0.1')
let deviceToken = ''
let createdId: string | undefined
const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> =>
new Promise((resolve, reject) => {
const req = https.request(
{
hostname: '127.0.0.1',
port: proxyPort,
path,
method,
ca: identity.cert,
checkServerIdentity,
headers: {
...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}),
'Content-Type': 'application/json'
}
},
res => {
let value = ''
res.on('data', chunk => (value += chunk))
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null })
} catch {
reject(new Error(`Non-JSON response: ${path}`))
}
})
}
)
req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`)))
req.on('error', reject)
req.end(body === undefined ? undefined : JSON.stringify(body))
})
try {
const pair = await call('/aiturk/pair/claim', 'POST', {
code: proxy.beginPairing().code,
device_name: 'Acceptance ephemeral'
})
expect(pair.status).toBe(200)
deviceToken = pair.data.token
for (const endpoint of [
'health',
'profiles',
'sessions',
'cron/jobs',
'cron/blueprints',
'cron/delivery-targets',
'model/info',
'skills',
'tools/toolsets',
'mcp/servers',
'plugins/kanban/boards'
]) {
const response = await call(`/api/${endpoint}?profile=default`)
expect(response.status, endpoint).toBe(200)
}
const created = await call('/api/cron/jobs?profile=default', 'POST', {
name: `AITURK mobile acceptance ${Date.now()}`,
schedule: '0 0 1 1 *',
prompt: 'Temporary acceptance record; deleted immediately without execution.',
deliver: 'local'
})
expect(created.status).toBe(200)
createdId = created.data.id
expect(typeof createdId).toBe('string')
const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}`
expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200)
expect(
(await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status
).toBe(200)
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
ca: identity.cert,
checkServerIdentity,
headers: { Authorization: `Bearer ${deviceToken}` }
})
const timer = setTimeout(() => {
ws.terminate()
reject(new Error('Chat ping timed out'))
}, 15000)
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} })))
ws.on('message', raw => {
const message = JSON.parse(String(raw))
if (message.id === 123) {
clearTimeout(timer)
ws.close()
message.error ? reject(new Error('Chat ping rejected')) : resolve()
}
})
ws.on('error', error => {
clearTimeout(timer)
reject(error)
})
})
} finally {
if (createdId) {
const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default`
expect((await call(route, 'DELETE')).status).toBe(200)
expect((await call(route)).status).toBe(404)
}
await proxy.stop()
}
},
180000
)
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>
+12
View File
@@ -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) }
}
+1 -1
View File
@@ -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">
+19 -1
View File
@@ -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>
+26
View File
@@ -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
+52
View File
@@ -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',
+7
View File
@@ -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',
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: [
'electron/mobile-*.test.ts',
'electron/first-run-setup*.test.ts',
'electron/primary-backend-startup.test.ts'
]
}
})
-38
View File
@@ -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.
+2 -19
View File
@@ -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/)
})
+29
View File
@@ -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.
#
+20
View File
@@ -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.
+4 -1
View File
@@ -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()
-41
View File
@@ -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
+54
View File
@@ -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)
+533
View File
@@ -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)
+21 -3
View File
@@ -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 = []
+3 -2
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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",
+194
View File
@@ -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.
+23
View File
@@ -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: []
+15
View File
@@ -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: []
+23
View File
@@ -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
+24
View File
@@ -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: []
+24
View File
@@ -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: []
+12
View File
@@ -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"
+17
View File
@@ -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: []
+16
View File
@@ -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: []
+65
View File
@@ -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.
+1
View File
@@ -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()
+300
View File
@@ -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
+2 -1
View File
@@ -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"
+44 -27
View File
@@ -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
+109
View File
@@ -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).
+39
View File
@@ -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
+16
View File
@@ -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:
+16
View File
@@ -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)
+5
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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],
+8
View File
@@ -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
+41 -2
View File
@@ -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})
+55
View File
@@ -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