Compare commits

...
Author SHA1 Message Date
yilsemandClaude Opus 5.5 b560950f82 fix(codex-bridge): ship zstd for the agent's Python and refuse to link without it
Codex compresses request bodies with zstd. The agent venv is Python 3.11, which
has no zstd in its standard library, so every Codex request failed on a fresh
install. The matching zstandard build now ships beside the bridge; the status
endpoint reports ok=false when bodies cannot be read, and the IDE then leaves
Codex's config alone. A running bridge from an older build is replaced, and
"Sync now" makes the bridge re-read the IDE session at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 16:27:55 +03:00
yilsemandClaude Opus 5.5 dea67fc153 feat(settings): Codex Bağlama, on by default
Settings → Codex Bağlama runs the bundled bridge for an OpenAI Codex install on
the same computer: the IDE's models and combos appear in Codex and switching
models keeps the conversation. The IDE points Codex's openai_base_url at the
bridge only after the bridge answers, keeps it running across logins, and
removes every trace when the setting is turned off. A Codex that already uses
another base URL is left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 15:59:39 +03:00
yilsemandClaude Opus 5.5 1ce5ab82cf feat(codex-bridge): ship the local Codex bridge with the IDE
Adds the IDE's models and combos to Codex's model list and keeps conversation
summaries readable across providers, so switching models in Codex no longer
drops the conversation. Standard-library Python, run on the agent venv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 15:46:10 +03:00
yilsemandClaude Opus 5.5 df63432c92 Remove the leftover Codex link visual fixture
The settings screen it rendered was reverted; it depended on a separate
manager process that is not shipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 15:18:07 +03:00
yilsem 4220d35ebe Revert "feat(settings): add Codex link controls and authenticated phone bridge"
This reverts commit 55a1beaf9d.
2026-10-09 15:17:59 +03:00
yilsem 01889d1383 Revert "Label provider token estimates in Codex memory settings"
This reverts commit 0fa42332c1.
2026-10-09 15:17:34 +03:00
yilsem 1282de09ba Prepare IDE beta.21 Codex connection settings release 2026-10-09 13:38:21 +03:00
yilsem 0fa42332c1 Label provider token estimates in Codex memory settings 2026-10-09 13:36:39 +03:00
yilsem 21aa3c2247 fix(test): separate browser preview from Playwright compiler project 2026-10-09 12:09:50 +03:00
yilsem 55a1beaf9d feat(settings): add Codex link controls and authenticated phone bridge 2026-10-09 11:55:16 +03:00
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
yilsemandClaude Opus 5 9240576937 fix(desktop): resolve hoisted mobile vendor packages when bundling
A clean npm ci hoists qrcode-generator and jsqr to the workspace root, so the bundler now follows Node resolution from apps/mobile instead of assuming apps/mobile/node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:51:08 +03:00
yilsemandClaude Opus 5 233885d3a7 fix(desktop): ship the VC++ runtime the Windows voice wheels need
onnxruntime (wake word) and ctranslate2 (local speech-to-text) link against
msvcp140.dll and msvcp140_1.dll, which the managed CPython does not ship.
On Windows without the system VC++ Redistributable both imports failed with
"DLL load failed: The specified module could not be found", so "Hey Hermes"
and voice chat transcription never worked.

- Bundle the Microsoft-signed VC143 CRT set (14.44.35211, matching the
  managed CPython's vcruntime140) as a Windows extra resource with a SHA-256
  manifest.
- Before every local backend start, copy missing or mismatching CRT files
  beside the managed python.exe (the process image directory, searched first),
  verifying digests; never overwrite the interpreter's own vcruntime and never
  block boot. Existing installs heal on their next launch.
- Report an unloadable native voice wheel as a VC++ runtime problem in the
  wake-word requirements, local transcription and `hermes doctor`, instead of
  treating installed package metadata as proof the wheel loads.
- Bump AITURK IDE to 1.0.0-beta.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:46:39 +03:00
yilsem 9a1d831d4b fix(desktop): complete Rolldown runtime dependency closure 2026-09-12 14:41:36 +03:00
yilsem 1fe367177c fix(windows): pin Vite Lightning CSS native dependency to matching version 2026-09-12 14:39:39 +03:00
yilsem 40c3c74282 fix(mobile): synchronize root lockfile QR dependencies 2026-09-12 14:36:33 +03:00
yilsem ee73fa8cce chore(desktop): prepare Windows x64 beta.11 candidate 2026-09-12 14:26:39 +03:00
yilsem f960716483 test(mobile): verify Windows host ancestry and 0.1.4 runtime integration 2026-09-12 12:17:16 +03:00
yilsem 5f75dcc99a feat(mobile): bind device identities and bound voice usage in 0.1.4 dev 2026-09-12 12:11:18 +03:00
yilsem ac6687181e fix(windows): probe compute host liveness without sending signals 2026-09-12 11:52:12 +03:00
yilsem 3f12c627ab fix(windows): append compute host logs without concurrent record loss 2026-09-12 11:49:46 +03:00
yilsem 4115f1b4c0 feat(mobile): add local QR scanning and desktop pairing invitations 2026-09-08 21:28:24 +03:00
yilsem c1929957a2 feat(mobile): connect Android and IDE to browser account login 2026-09-08 21:10:49 +03:00
yilsem b66daed07a feat(mobile): correlate voice tasks and recover results on reconnect 2026-09-08 21:00:28 +03:00
yilsem 9f9f3226a1 feat(mobile): add Android development client and scoped IDE companion 2026-09-08 16:54:28 +03:00
213 changed files with 28178 additions and 157 deletions

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
# 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
- name: Codex koprusu (Python, bagimliliksiz) testleri
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
if ($py) { & $py -m unittest discover -s apps/desktop/assets/codex-bridge }
else { & py -3 -m unittest discover -s apps/desktop/assets/codex-bridge }
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,21 @@
# AITURK IDE Codex bridge
Local HTTP proxy that AITURK IDE's **Settings → Codex Bağlama** runs for OpenAI Codex
(desktop or CLI) on the same computer. Standard-library Python only; it runs on the
agent venv's interpreter and keeps running after the IDE window closes, because
Codex depends on it while `openai_base_url` points here.
- `bridge.py`: the proxy. Subscription models go to chatgpt.com unchanged; `ide/<id>`
models go to the AITURK gateway configured in the agent's `config.yaml`, with the
key read from the agent's `.env` on every catalog refresh (never written elsewhere).
- `ide_catalog.py`: which IDE models and combos are added to Codex's model list and
how they are named (`provider/model`, combos by bare name).
- `ozet_kopru.py`: conversation summaries are produced as plain text, so a summary made
by one provider stays readable after the user switches to another.
- `gemini_context.py`, `resim_context.py`: request fixes for routed models.
Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE writes
`<data dir>/bridge.json` and reads `GET /aiturk/codex-baglama/status`;
`POST /aiturk/codex-baglama/stop` ends the process.
Tests: `python -m unittest discover` in this directory.
+953
View File
@@ -0,0 +1,953 @@
"""AITURK IDE Codex bridge.
A small local HTTP proxy between OpenAI Codex (desktop or CLI) and its backend.
AITURK IDE's "Codex Baglama" setting points Codex's `openai_base_url` at it.
- POST .../responses -> by model slug:
* subscription models (no mapping) -> chatgpt.com, unchanged
* "ide/<id>" models -> the AITURK gateway the IDE itself uses
- GET .../models -> the IDE's models and combos are added to Codex's own list
- everything else -> chatgpt.com, unchanged (voice, remote, auth)
- Conversation summaries (compaction) are produced as plain text so that every
provider can read them when the user switches models (ozet_kopru.py).
Data directory (bridge.json, log/): --home <dir> or AITURK_CODEX_BRIDGE_HOME,
otherwise the script's own directory. Agent home: --hermes-home or HERMES_HOME.
Local control: GET /aiturk/codex-baglama/status, POST /aiturk/codex-baglama/stop.
"""
import http.server, urllib.request, urllib.error, json, os, sys, time, threading, re, copy, hashlib
from gemini_context import coalesce_context
try:
from kanca_context import pano_blogunu_ulastir # optional, site-specific hook context
except ImportError:
def pano_blogunu_ulastir(req):
return 0, ""
from resim_context import resimleri_sadelestir
import ide_catalog
import ozet_kopru
SURUM = "2"
HERE = os.path.dirname(os.path.abspath(__file__))
# Codex sends request bodies zstd-compressed. Python gained zstd in 3.14; for older
# interpreters the matching `zstandard` build ships next to this file.
_VENDOR = os.path.join(HERE, "vendor", "cp%d%d-win_amd64" % sys.version_info[:2])
if os.path.isdir(_VENDOR):
sys.path.insert(0, _VENDOR)
def _zstd_decompress(body):
try:
from compression import zstd as _z
return _z.decompress(body)
except ImportError:
import zstandard
return zstandard.ZstdDecompressor().decompressobj().decompress(body)
def _zstd_ready():
try:
try:
from compression import zstd as _z
sample = _z.compress(b"aiturk")
except ImportError:
import zstandard
sample = zstandard.ZstdCompressor().compress(b"aiturk")
return _zstd_decompress(sample) == b"aiturk"
except Exception:
return False
# Without zstd every Codex request would fail; the status endpoint then reports
# ok=false so that the IDE never points Codex at this bridge.
ZSTD_OK = _zstd_ready()
def _arg(name, env):
"""--name <value> from the command line, else the environment variable."""
if name in sys.argv[1:]:
k = sys.argv.index(name)
if k + 1 < len(sys.argv):
return sys.argv[k + 1]
return os.environ.get(env) or ""
HOME = _arg("--home", "AITURK_CODEX_BRIDGE_HOME") or HERE
if _arg("--hermes-home", "HERMES_HOME"):
os.environ["HERMES_HOME"] = _arg("--hermes-home", "HERMES_HOME")
try:
with open(os.path.join(HOME, "bridge.json"), encoding="utf-8") as _f:
CFG = json.load(_f)
except (OSError, ValueError):
CFG = {} # no settings file: subscription models pass through, IDE catalog on
PORT = int(CFG.get("port", 8790))
CHATGPT = CFG.get("chatgpt_backend", "https://chatgpt.com").rstrip("/")
ROUTES = dict(CFG.get("routes") or {}) # {"cheapvibecode": {"url","key","flatten_tools"}, ...}
RETRIES = int(CFG.get("retries", 6))
# Socket idle (per read) timeout toward 9Router/chatgpt.com. 9Router and OmniRoute keep
# long answers alive with a byte every 15 s, so this only fires on a dead peer; 60+ min
# per user decision (28 Sep 2026) so a heavy Fusion is never cut here.
UPSTREAM_IDLE_TIMEOUT = float(CFG.get("upstream_idle_timeout", 3600))
def is_timeout(error):
"""True for a socket/read timeout, also when urllib wraps it in URLError."""
reason = getattr(error, "reason", None)
return isinstance(error, TimeoutError) or isinstance(reason, TimeoutError)
DEBUG = bool(CFG.get("debug", False))
DISPLAY_MAP = CFG.get("display_map", {})
UA = CFG.get("user_agent", "AstraBridge/1.0 (codex)")
# Bu metinleri iceren hatalar kalicidir; yeniden denemek yerine hemen Codex'e iletilir.
NO_RETRY_PATTERNS = CFG.get("no_retry_patterns", ["All fusion panel models failed", "model not found", "does not exist", "insufficient_balance", "allowance exhausted"])
LOGDIR = os.path.join(HOME, "log")
os.makedirs(LOGDIR, exist_ok=True)
lock = threading.Lock()
seq = [0]
def log(msg):
line = time.strftime("%H:%M:%S ") + msg
print(line, flush=True)
with lock:
with open(os.path.join(LOGDIR, "bridge.log"), "a", encoding="utf-8") as f:
f.write(line + "\n")
# ---- model yonlendirme tablosu: slug -> (route, hedef model) ----
MODEL_MAP = {} # slug -> (route, hedef model, flatten_tools or None)
for slug, v in CFG.get("model_map", {}).items():
if isinstance(v, str):
v = {"route": "cheapvibecode", "model": v}
MODEL_MAP[slug] = (v["route"], v["model"], v.get("flatten_tools"))
EXTRA_MODELS = list(CFG.get("extra_models", []))
for m in EXTRA_MODELS:
MODEL_MAP.setdefault(m["slug"], (m.get("route", "cheapvibecode"), m.get("target", m["slug"]), m.get("flatten_tools")))
def load_9router_catalog():
global MODEL_MAP, EXTRA_MODELS
cat = CFG.get("nine_router_catalog") or {}
if not cat.get("enabled"):
return
route = cat.get("route", "9router")
r = ROUTES.get(route)
if not r:
return
prefix = cat.get("prefix", "9r/")
try:
req = urllib.request.Request(r["url"].rsplit("/responses", 1)[0] + "/models",
headers={"Authorization": f"Bearer {r['key']}", "User-Agent": UA})
data = json.load(urllib.request.urlopen(req, timeout=30))
except Exception as e:
log(f"9Router katalogu alinamadi: {e}")
return
model_map = {k: v for k, v in MODEL_MAP.items() if not k.startswith(prefix)}
extra_models = [m for m in EXTRA_MODELS if not m["slug"].startswith(prefix)]
n = 0
# Secicide yalniz "saglayici/model" gorunur (kullanici istegi, 9 Ekim 2026).
adlar = ide_catalog.adlandir([(m.get("id", ""), m.get("id", "")) for m in data.get("data", []) if m.get("id")])
for m in data.get("data", []):
mid = m.get("id", "")
slug = prefix + mid
if not mid or slug in model_map:
continue
extra_models.append({"slug": slug, "display_name": adlar.get(mid, mid),
"description": f"9Router: {mid}", "route": route, "target": mid,
"clone_from": cat.get("clone_from", "gpt-6-astra"),
"priority": 100 + n, "reasoning_levels": cat.get("reasoning_levels"),
"context_window": m.get("context_window") or m.get("context_length")})
model_map[slug] = (route, mid, None)
n += 1
MODEL_MAP = model_map
EXTRA_MODELS = extra_models
log(f"9Router katalogu: {n} model eklendi")
load_9router_catalog()
# ---- AITURK IDE "Codex Baglama": IDE'nin model/combolari (ide_catalog.py, 9 Ekim 2026) ----
IDE_PREFIX = "ide/"
IDE_ROUTE = "aiturk-ide"
IDE_DURUM = [None]
def load_ide_catalog():
"""IDE'nin gecit katalogunu "ide/<id>" olarak ekler. Ayar kapaliysa ya da IDE anahtari
yoksa girdiler kaldirilir; gecit gecici olarak cevap vermezse son liste korunur."""
global MODEL_MAP, EXTRA_MODELS
try:
durum, base, anahtar, girdiler = ide_catalog.yukle(UA)
except Exception as e:
log(f"AITURK IDE katalogu alinamadi, son liste korunuyor: {e}")
return
model_map = {k: v for k, v in MODEL_MAP.items() if not k.startswith(IDE_PREFIX)}
extra_models = [m for m in EXTRA_MODELS if not m["slug"].startswith(IDE_PREFIX)]
n = 0
if durum == "tamam":
# Anahtar yalniz bellekte tutulur; bridge.json'a yazilmaz.
ROUTES[IDE_ROUTE] = {"url": base + "/responses", "key": anahtar, "flatten_tools": True}
clone = (CFG.get("nine_router_catalog") or {}).get("clone_from", "gpt-6-astra")
levels = (CFG.get("nine_router_catalog") or {}).get("reasoning_levels")
# Secicide yalniz "saglayici/model" gorunur. 9Router listesinde ayni adla zaten duran model
# ikinci kez eklenmez (ayni modelin iki satir olmamasi icin).
var_olan = {m.get("display_name", "").lower() for m in extra_models}
for g in girdiler:
slug = IDE_PREFIX + g["id"]
g["kisa"] = ide_catalog.kisa_ad(g["ad"])
# "ad (2)": ayni modelin gecitteki ikinci yolu; taban ad listede varsa o da eklenmez.
girdiler = [g for g in girdiler if re.sub(r" \(\d+\)$", "", g["kisa"]).lower() not in var_olan]
adlar = ide_catalog.adlandir([(g["id"], g["ad"]) for g in girdiler], dolu=var_olan)
for g in girdiler:
slug = IDE_PREFIX + g["id"]
if slug in model_map:
continue
extra_models.append({"slug": slug, "display_name": adlar[g["id"]],
"description": f"AITURK IDE: {g['ad']}", "route": IDE_ROUTE, "target": g["id"],
"clone_from": clone, "priority": 300 + n, "reasoning_levels": levels})
model_map[slug] = (IDE_ROUTE, g["id"], None)
n += 1
else:
ROUTES.pop(IDE_ROUTE, None)
MODEL_MAP = model_map
EXTRA_MODELS = extra_models
ozet = f"{durum}, {n} model"
if IDE_DURUM[0] != ozet: # 5 dakikada bir ayni satiri yazmamak icin
IDE_DURUM[0] = ozet
log(f"AITURK IDE Codex Baglama: {ozet}")
load_ide_catalog()
def catalog_refresh_loop():
"""Kataloglari arka planda yeniler; liste istegini yavaslatmaz."""
minutes = max(1, int(CFG.get("catalog_refresh_min", 5)))
while True:
time.sleep(minutes * 60)
for yukle in (load_9router_catalog, load_ide_catalog):
try:
yukle()
except Exception as e:
log(f"katalog yenileme hatasi: {e}")
threading.Thread(target=catalog_refresh_loop, daemon=True).start()
LAST_MODELS = {} # hesap kimligi -> son basarili model listesi (chatgpt.com hata verirse bu verilir)
CACHE_DIR = os.path.join(LOGDIR, "son-liste")
def _cache_path(acct):
guvenli = re.sub(r"[^A-Za-z0-9._-]", "_", acct or "-")[:64]
return os.path.join(CACHE_DIR, f"models-{guvenli}.json")
def save_last_models(acct, hdrs, body):
"""Son basarili listeyi diske yazar. Kopru yeniden baslayinca bellek
sifirlandigi icin, restarttan sonraki ilk 401 listeyi silip goturuyordu."""
try:
os.makedirs(CACHE_DIR, exist_ok=True)
yol = _cache_path(acct)
veri = {"acct": acct, "headers": hdrs, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
"body": body.decode("utf-8")}
gecici = yol + ".tmp"
with open(gecici, "w", encoding="utf-8") as f:
json.dump(veri, f, ensure_ascii=False)
os.replace(gecici, yol) # yarim dosya birakmamak icin
except Exception as e:
log(f"son liste diske yazilamadi: {e}")
def load_last_models():
"""Acilista diskteki son listeleri bellege alir."""
try:
if not os.path.isdir(CACHE_DIR):
return
for ad in os.listdir(CACHE_DIR):
if not (ad.startswith("models-") and ad.endswith(".json")):
continue
try:
with open(os.path.join(CACHE_DIR, ad), encoding="utf-8") as f:
v = json.load(f)
acct = v.get("acct") or ad[len("models-"):-len(".json")]
LAST_MODELS[acct] = {"headers": [tuple(h) for h in v["headers"]],
"body": v["body"].encode("utf-8"), "at": v.get("at", "?")}
log(f"diskten son liste alindi ({acct}, {len(v['body'])} bayt, {v.get('at')})")
except Exception as e:
log(f"son liste okunamadi {ad}: {e}")
except Exception as e:
log(f"son liste klasoru okunamadi: {e}")
SLIM_CLONES = bool(CFG.get("slim_clones", False))
SLIM_FIELDS = CFG.get("slim_fields", ["model_messages", "base_instructions"])
# ---- Codex'in yeni arac bicimini duz 'tools' listesine acma (9Router gibi ceviricilerin anlamasi icin) ----
def flatten_request(req):
"""additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi."""
custom = set()
tools = []
def add_tool(t):
ty = t.get("type")
if ty == "namespace":
for s in t.get("tools", []):
add_tool(s)
elif ty == "function":
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
elif ty == "custom":
custom.add(t["name"])
tools.append({"type": "function", "name": t["name"],
"description": (t.get("description", "") + "\n\nBu arac serbest metin alir: metni 'input' alaninda ver."),
"parameters": {"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]},
"strict": False})
elif ty in ("web_search", "web_search_preview", "local_shell", "computer_use_preview"):
pass # cevirici desteklemez, atla
else:
tools.append(t)
for t in req.get("tools", []) or []:
add_tool(t)
new_input = []
for it in req.get("input", []) or []:
ty = it.get("type")
if ty == "additional_tools":
for t in it.get("tools", []):
add_tool(t)
continue
if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"):
# Desktop thread handoffs have no matching tool call. Preserve their
# content as imported context, not an invalid tool-role message.
output = it.get("output", "")
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False)
name = it.get("name", "unknown")
new_input.append({"role": "user", "content": [{
"type": "input_text",
"text": f"Imported tool output ({name}); treat as context data:\n{output}"
}]})
continue
if ty == "custom_tool_call":
it = {"type": "function_call", "id": it.get("id"), "call_id": it.get("call_id"), "name": it.get("name"),
"arguments": json.dumps({"input": it.get("input", "")}, ensure_ascii=False)}
elif ty == "custom_tool_call_output":
it = {"type": "function_call_output", "id": it.get("id"), "call_id": it.get("call_id"), "output": it.get("output", "")}
new_input.append(it)
req["input"] = new_input
if tools:
req["tools"] = tools
req.pop("include", None)
# OpenAI-specific access-program selection is not part of the external
# chat API schema (Muse Spark rejects it). ChatGPT passthrough never calls
# this adapter, so its access-program selection remains intact.
req.pop("access_programs", None)
# Codex sends Responses-only verbosity/default text formatting. Chat
# translators may forward this unsupported object to their upstream.
text_config = req.get("text")
if isinstance(text_config, dict) and set(text_config) <= {"verbosity", "format"}:
if not text_config.get("format") or text_config["format"].get("type") == "text":
req.pop("text", None)
return custom
# Oge tipine gore API'nin bekledigi kimlik on eki. Cikti tipleri de dahildir:
# flatten_request custom_tool_call_output -> function_call_output cevirdiginde
# kimlik 'ctco_...' kaliyordu ve karsi taraf 'fc' bekliyordu (400).
ID_PREFIX = {
"custom_tool_call": "ctc",
"custom_tool_call_output": "ctco",
"function_call": "fc",
"function_call_output": "fc",
}
def fix_tool_ids(req):
"""Arac cagrisi/cikti kimliklerinin on ekini hedef API'nin bekledigine cevirir.
Codex gecmisinde kalan yanlis on ekli kimlikler (or. fc_toolu_..., ctco_...)
400 hatasina yol aciyordu."""
changed = 0
for it in req.get("input", []) or []:
if not isinstance(it, dict):
continue
want = ID_PREFIX.get(it.get("type"))
i = it.get("id")
if not want or not isinstance(i, str) or not i:
continue
# Alt cizgi ile karsilastir: 'ctco_x'.startswith('ctc') dogru dondugu icin
# ctco kimlikleri yanlislikla "zaten dogru" sayiliyordu.
if i.startswith(want + "_"):
continue
tail = i.split("_", 1)[1] if "_" in i else i
it["id"] = want + "_" + tail
changed += 1
return changed
# Cevirici uretimi kimliklerin izi. 9Router chat.completions'a cevirdigi icin
# uydurdugu kimliklerde bu parca bulunur; chatgpt.com boyle bir kaydi saklamaz.
FOREIGN_ID_MARK = "chatcmpl-"
# Icerigini tasimayan, yalnizca hedefteki kayda ISARET EDEN oge tipleri.
# Kimlik yabanciysa hedef cozemez; ceviriyle kurtarilamaz, atilmalari gerekir.
REFERENCE_TYPES = ("reasoning", "item_reference")
# Icerigini kendisi tasiyan tipler: yabanci kimlik tasisalar da sorun degil,
# uyari uretmezler. ID_PREFIX'teki tipler de bu gruba dahildir.
INLINE_TYPES = ("message",)
def drop_foreign_items(req):
"""Baska bir saglayicinin urettigi, hedefin cozemeyecegi ogeleri atar.
Ilke hermes-agent'tan: sahipli kimligi tasima. hermes gecmisi saglayicidan
bagimsiz tuttugu icin model degistirince hicbir sey kirilmiyor; Codex ise
Responses bicimindeki sahipli kimlikleri tasiyor. Model degistirildiginde
chatgpt.com 404 'Item ... not found. Items are not persisted when store is
set to false' diyor ve tur hic baslamiyordu.
Icerigini iceren ogeler (function_call vb.) atilmaz; onlarin kimligi
fix_tool_ids ile duzeltilir. Yalnizca isaret eden tipler atilir."""
inp = req.get("input")
if not isinstance(inp, list):
return 0
kalan, atilan, bilinmeyen = [], 0, set()
for it in inp:
i = it.get("id") if isinstance(it, dict) else None
if isinstance(i, str) and FOREIGN_ID_MARK in i:
ty = it.get("type")
if ty in REFERENCE_TYPES:
atilan += 1
continue
if ty not in ID_PREFIX and ty not in INLINE_TYPES:
# Tanimadigimiz bir tip yabanci kimlik tasiyor. Simdilik
# gecirilir; hata verirse gunlukte hangi tip oldugu yazili olur.
bilinmeyen.add(ty)
kalan.append(it)
if atilan:
req["input"] = kalan
if bilinmeyen:
log(f"UYARI: yabanci kimlikli taninmayan oge tipleri gecirildi: {sorted(bilinmeyen)}")
return atilan
class SseRewriter:
"""function_call -> custom_tool_call donusumu (custom arac adlari icin), satir satir SSE."""
def __init__(self, custom):
self.custom = custom
self.buf = b""
self.custom_ids = set()
def _fix(self, d):
t = d.get("type", "")
item = d.get("item")
if item and item.get("type") == "function_call" and item.get("name") in self.custom:
self.custom_ids.add(item.get("id"))
args = item.get("arguments") or ""
try:
inp = json.loads(args).get("input", "") if args else ""
except Exception:
inp = args
d["item"] = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
"name": item.get("name"), "input": inp, "status": item.get("status", "completed")}
return d
if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids:
return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor
if t == "response.completed" and d.get("response", {}).get("output"):
out = []
for item in d["response"]["output"]:
if item.get("type") == "function_call" and item.get("name") in self.custom:
try: inp = json.loads(item.get("arguments") or "{}").get("input", "")
except Exception: inp = item.get("arguments", "")
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
"name": item.get("name"), "input": inp, "status": "completed"}
out.append(item)
d["response"]["output"] = out
return d
def feed(self, chunk):
self.buf += chunk
out = b""
while b"\n\n" in self.buf:
block, self.buf = self.buf.split(b"\n\n", 1)
lines = block.split(b"\n")
data_idx = [i for i, l in enumerate(lines) if l.startswith(b"data:")]
if not data_idx:
out += block + b"\n\n"; continue
try:
d = json.loads(b"\n".join(lines[i][5:].strip() for i in data_idx))
except Exception:
out += block + b"\n\n"; continue
d = self._fix(d)
if d is None:
continue
ev = [l for l in lines if l.startswith(b"event:")]
evline = (b"event: " + d.get("type", "").encode() + b"\n") if ev else b""
out += evline + b"data: " + json.dumps(d, ensure_ascii=False).encode("utf-8") + b"\n\n"
return out
def flush(self):
b, self.buf = self.buf, b""
return b
RESP_HOP = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection", "content-encoding"}
REQ_HOP = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection"}
def claude_sonu_duzelt(req):
"""9Router Claude cevirisi developer/system mesajlarini dusurur; geriye kalan son oge asistan mesaji ya da
reasoning ise Claude 'assistant message prefill' hatasi (400) verir. O durumda sona kisa user mesaji eklenir.
Doner: (eklendi_mi, son ogelerin kisa ozeti)."""
items = req.get("input")
if not isinstance(items, list):
return False, ""
ozet = []
for it in reversed(items):
if not isinstance(it, dict):
continue
tip, rol = it.get("type", "message"), it.get("role")
ozet.append(f"{tip}/{rol}" if rol else tip)
if tip == "message" and rol in ("developer", "system"):
continue
if (tip == "message" and rol == "assistant") or tip == "reasoning":
items.append({"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"}]})
return True, ",".join(ozet[:3])
return False, ""
return False, ""
def decompress(body, enc):
enc = (enc or "").lower().strip()
if enc == "gzip":
import gzip; return gzip.decompress(body)
if enc == "deflate":
import zlib; return zlib.decompress(body)
if enc == "zstd":
return _zstd_decompress(body)
if enc == "br":
import brotli; return brotli.decompress(body)
return body
OZET_FRENI = ozet_kopru.DonguFreni()
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): pass
def _read(self):
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
out = bytearray()
while True:
line = self.rfile.readline().strip()
if not line:
continue
size = int(line.split(b";")[0], 16)
if size == 0:
while self.rfile.readline().strip():
pass
break
out += self.rfile.read(size)
self.rfile.readline()
return bytes(out)
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _stream(self, resp, status, tag, rewriter=None):
self.send_response(status)
for k, v in resp.headers.items():
if k.lower() in RESP_HOP: continue
self.send_header(k, v)
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
total = 0
def w(b):
if b:
self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n"); self.wfile.flush()
try:
while True:
chunk = resp.read(4096)
if not chunk: break
total += len(chunk)
w(rewriter.feed(chunk) if rewriter else chunk)
if rewriter: w(rewriter.flush())
self.wfile.write(b"0\r\n\r\n"); self.wfile.flush()
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
log(f"{tag} istemci baglantiyi kapatti ({total} bayt)")
log(f"{tag} bitti status={status} {total} bayt")
def _send_json(self, status, obj):
b = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def _control(self, path):
"""Local status/stop for AITURK IDE. Loopback only; carries no secrets."""
if self.command == "POST" and path.rstrip("/").endswith("/refresh"):
# The IDE asks for this after a sign-in or a settings change, so the model
# list does not wait for the next periodic refresh.
try:
load_ide_catalog()
except Exception as e:
log(f"katalog yenileme hatasi: {e}")
path = "/aiturk/codex-baglama/status"
self.command = "GET"
if self.command == "GET" and path.rstrip("/").endswith("/status"):
durum, _, sayi = (IDE_DURUM[0] or "kapali, 0 model").partition(", ")
return self._send_json(200, {"ok": ZSTD_OK, "surum": SURUM, "zstd": ZSTD_OK, "ide_durum": durum,
"ide_model": int((sayi.split() or ["0"])[0]),
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False))})
if self.command == "POST" and path.rstrip("/").endswith("/stop"):
self._send_json(200, {"ok": True})
log("AITURK IDE istegiyle kapatiliyor")
threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
return
return self._send_json(404, {"error": {"message": "unknown control path"}})
def _handle(self):
with lock:
seq[0] += 1; n = seq[0]
if "websocket" in (self.headers.get("Upgrade") or "").lower():
log(f"[{n} ws-upgrade {self.path[:60]}] -> 426, HTTP'ye dusuruluyor")
self.send_response(426)
self.send_header("Content-Length", "0")
self.send_header("Connection", "close")
self.end_headers()
return
body = self._read()
path = self.path
if path.startswith("/aiturk/codex-baglama/"):
return self._control(path)
if DEBUG:
log(f"[{n} giris {self.command} {path[:70]}] cl={self.headers.get('Content-Length')} enc={self.headers.get('Content-Encoding')} govde={len(body)}")
if self.command == "POST" and path.rstrip("/").endswith("/responses"):
return self._responses(n, path, body)
return self._passthrough(n, path, body)
def _passthrough(self, n, path, body, tagname="pass", rewritten=False, rewriter=None):
tag = f"[{n} {tagname} {self.command} {path[:80]}]"
hdrs = {k: v for k, v in self.headers.items() if k.lower() not in REQ_HOP}
if rewritten:
hdrs = {k: v for k, v in hdrs.items() if k.lower() != "content-encoding"}
if self.command == "GET" and "/models" in path:
# The upstream validator knows nothing about the added 9Router catalog.
hdrs = {k: v for k, v in hdrs.items() if k.lower() not in ("if-none-match", "if-modified-since")}
req = urllib.request.Request(CHATGPT + path, data=body or None, headers=hdrs, method=self.command)
try:
resp = urllib.request.urlopen(req, timeout=UPSTREAM_IDLE_TIMEOUT); status = resp.status
except urllib.error.HTTPError as e:
resp = e; status = e.code
except Exception as e:
log(f"{tag} HATA {e}"); return self._send_json(502, {"error": {"message": f"bridge passthrough: {e}"}})
if self.command == "GET" and "/models" in path:
acct = self.headers.get("chatgpt-account-id") or "-"
tok = (self.headers.get("Authorization") or "")[-8:]
if status == 200:
out = self._models(resp, tag)
return out
body = resp.read()[:300]
log(f"{tag} chatgpt.com {status} verdi (hesap={acct} token=..{tok}) govde={body[:160]!r}")
cached = LAST_MODELS.get(acct)
if cached:
self.send_response(200)
for k, v in cached["headers"]:
self.send_header(k, v)
self.send_header("Content-Length", str(len(cached["body"])))
self.end_headers(); self.wfile.write(cached["body"])
log(f"{tag} son basarili liste verildi ({len(cached['body'])} bayt, {cached['at']})")
return
self.send_response(status)
self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body)))
self.end_headers(); self.wfile.write(body); return
if status >= 400:
errbody = resp.read()
log(f"{tag} chatgpt.com {status}: {errbody[:300]!r}")
self.send_response(status)
for k, v in resp.headers.items():
if k.lower() in RESP_HOP: continue
self.send_header(k, v)
self.send_header("Content-Length", str(len(errbody)))
self.end_headers(); self.wfile.write(errbody); return
if (self.command == "POST" and "/realtime/calls" in path and status in (200, 201)
and (CFG.get("voice_prep") or {}).get("enabled")):
# Sesli on hazirlayici (bridge.json voice_prep.enabled). Hata atmaz; basliklar yalniz bellekte.
try:
import voice_prep
if voice_prep.baslat(resp.headers.get("Location"), hdrs, os.path.join(HOME, "bridge.json"), LOGDIR):
log(f"{tag} sesli on hazirlayici baslatildi")
except Exception as e:
log(f"{tag} sesli on hazirlayici baslatilamadi: {type(e).__name__}")
self._stream(resp, status, tag, rewriter)
def _openai_ozet_coz(self, n, oge, ozet_cfg):
"""OpenAI'nin sifreli ozetini bir kez OpenAI'ye duz metne dokturur; sonucu diske onbellekler (icerik gunluge yazilmaz)."""
klasor = os.path.join(LOGDIR, "ozet-onbellek")
metin = ozet_kopru.onbellekten(klasor, oge)
if metin:
return metin
govde = ozet_kopru.coz_istegi(oge, ozet_cfg.get("openai_modeli", "gpt-6-luna"))
hdrs = {k: v for k, v in self.headers.items() if k.lower() not in REQ_HOP and k.lower() != "content-encoding"}
hdrs["Content-Type"] = "application/json"; hdrs["Accept"] = "text/event-stream"
r = urllib.request.Request(CHATGPT + self.path, data=json.dumps(govde, ensure_ascii=False).encode("utf-8"), headers=hdrs, method="POST")
try:
resp = urllib.request.urlopen(r, timeout=float(ozet_cfg.get("coz_zaman_asimi", 300)))
metin = ozet_kopru.sse_metni(resp.read())
except urllib.error.HTTPError as e:
log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrilemedi: HTTP {e.code} {e.read()[:200]!r}")
return None
except Exception as e:
log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrilemedi: {type(e).__name__}")
return None
if metin:
ozet_kopru.onbellege(klasor, oge, metin)
log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrildi ({len(metin)} karakter, onbellege yazildi)")
return metin or None
def _models(self, resp, tag):
raw = resp.read()
try:
raw = decompress(raw, resp.headers.get("Content-Encoding"))
data = json.loads(raw)
models = data.get("models", [])
changed = 0
for m in models:
new = DISPLAY_MAP.get(m.get("slug"))
if new:
m["display_name"] = new; changed += 1
by_slug = {m.get("slug"): m for m in models}
for x in EXTRA_MODELS:
if x["slug"] in by_slug:
continue
base = by_slug.get(x.get("clone_from") or "gpt-6-astra") or (models[0] if models else {})
m = copy.deepcopy(base)
m["slug"] = x["slug"]
m["display_name"] = x.get("display_name", x["slug"])
m["description"] = x.get("description", "")
m["visibility"] = "list"
m["priority"] = x.get("priority", 0)
m["upgrade"] = None; m["availability_nux"] = None
m["additional_speed_tiers"] = []; m["service_tiers"] = []
if x.get("context_window"):
cw = int(x["context_window"])
m["context_window"] = cw; m["max_context_window"] = cw
if x.get("reasoning_levels"):
m["supported_reasoning_levels"] = [l for l in m.get("supported_reasoning_levels", []) if l.get("effort") in x["reasoning_levels"]]
if SLIM_CLONES:
saved = {f_: m[f_] for f_ in SLIM_FIELDS if f_ in m}
for f_ in SLIM_FIELDS:
m.pop(f_, None)
# Codex, bir girdide hem base_instructions hem
# model_messages.instructions_template yoksa listenin TAMAMINI
# reddeder. Kirpma sonrasi ikisi de kalmadiysa geri koy.
mm = m.get("model_messages")
has_tmpl = isinstance(mm, dict) and mm.get("instructions_template")
if not m.get("base_instructions") and not has_tmpl:
m.update(saved)
models.append(m); changed += 1
out = json.dumps(data, ensure_ascii=False).encode("utf-8")
except Exception as e:
log(f"{tag} model listesi degistirilemedi: {e}")
out = raw; changed = 0
raw_len = len(out)
catalog_etag = 'W/"astrabridge-' + hashlib.sha256(out).hexdigest()[:24] + '"'
enc_out = None
if "gzip" in (self.headers.get("Accept-Encoding") or "").lower() and changed:
import gzip
out = gzip.compress(out, compresslevel=6); enc_out = "gzip"
hdrs_out = []
for k, v in resp.headers.items():
kl = k.lower()
if kl in RESP_HOP or kl in ("date", "set-cookie", "cf-ray"): continue
if kl in ("etag", "x-models-etag") and changed:
v = catalog_etag
hdrs_out.append((k, v))
if enc_out:
hdrs_out.append(("Content-Encoding", enc_out))
if changed and not enc_out:
acct_ = self.headers.get("chatgpt-account-id") or "-"
onceki = LAST_MODELS.get(acct_)
LAST_MODELS[acct_] = {"headers": hdrs_out, "body": out, "at": time.strftime("%H:%M:%S")}
# Liste 4 dakikada bir isteniyor; sadece icerik degistiyse diske yaz.
if not onceki or onceki.get("body") != out:
save_last_models(acct_, hdrs_out, out)
self.send_response(200)
for k, v in hdrs_out:
self.send_header(k, v)
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
log(f"{tag} bitti status=200 {raw_len} bayt (gonderilen {len(out)}, {enc_out or 'sikistirmasiz'}), {changed} model girdisi degistirildi/eklendi")
def _responses(self, n, path, body):
try:
req = json.loads(decompress(body, self.headers.get("Content-Encoding")))
except Exception as e:
log(f"[{n} responses] govde cozulemedi: {e}")
return self._send_json(400, {"error": {"message": f"bridge: govde cozulemedi: {e}"}})
slug = req.get("model", "")
mapping = MODEL_MAP.get(slug)
ozet_cfg = CFG.get("ozet_kopru") or {}
ozet_acik = ozet_cfg.get("enabled", False)
catalog = CFG.get("nine_router_catalog") or {}
prefix = catalog.get("prefix", "9r/")
if mapping is None and catalog.get("enabled") and slug.startswith(prefix):
# Newly added API models can be selected before the background refresh.
# Provider-qualified IDs are passed intact to 9Router, never to ChatGPT.
mapping = (catalog.get("route", "9router"), slug[len(prefix):], None)
ide_prefix, ide_route = "ide/", "aiturk-ide" # load_ide_catalog ile ayni degerler
if mapping is None and slug.startswith(ide_prefix) and ide_route in ROUTES:
mapping = (ide_route, slug[len(ide_prefix):], None)
if mapping is None and slug.startswith(ide_prefix):
# Never send an IDE model to chatgpt.com under another name.
log(f"[{n} IDE {slug}] Codex Baglama kapali ya da IDE anahtari yok")
return self._send_json(503, {"error": {"message": "AITURK IDE Codex Baglama kapali ya da IDE oturumu bulunamadi; bu model su an kullanilamiyor."}})
if mapping is None:
# abonelik modeli: chatgpt.com'a oldugu gibi
fixed = fix_tool_ids(req)
atilan = drop_foreign_items(req)
degisti = False
ozet_akisi = None
if ozet_acik:
# Ozet bagdastirici (ozet_kopru.py; 9 Ekim 2026'da yalniz bu parca geri acildi): koprunun urettigi
# duz metin ozetleri OpenAI modeline de mesaj olarak gider.
try:
ob = ozet_kopru.ozetleri_ac(req, yabanci_hedef=False)
# kapsam "hepsi": OpenAI modelinde de ozeti kopru DUZ METIN urettirir; boylece ozet sonradan
# secilen her saglayicida okunur. kapsam "9router": OpenAI ozeti yerlesik (sifreli) kalir.
if ozet_cfg.get("kapsam", "hepsi") == "hepsi" and ozet_kopru.tetik_mi(req):
OZET_FRENI.sinir = int(ozet_cfg.get("dongu_siniri", 3)); OZET_FRENI.pencere = int(ozet_cfg.get("dongu_pencere_sn", 600))
if not OZET_FRENI.izin(str(req.get("prompt_cache_key") or slug), time.time()):
log(f"[{n} CHATGPT {slug}] ozet kopru: DONGU FRENI, ozet istegi reddedildi")
return self._send_json(400, {"error": {"message": ozet_kopru.DONGU_MESAJI % OZET_FRENI.sinir,
"type": "invalid_request_error", "code": "astrabridge_ozet_dongusu"}})
ozet_kopru.ozet_istegi_hazirla(req, ozet_cfg.get("istem"))
ozet_akisi = ozet_kopru.OzetAkisi(None, tohum=str(n))
ob["acilan"] += 1
log(f"[{n} CHATGPT {slug}] ozet kopru: sikistirma istegi duz metin ozet istegine cevrildi (girdi={len(req.get('input', []))} oge)")
except Exception as e:
ob = {"acilan": 0}; ozet_akisi = None
log(f"[{n} CHATGPT {slug}] ozet kopru atlandi: {type(e).__name__}: {e}")
if ob["acilan"]:
degisti = True
if ozet_akisi is None:
log(f"[{n} CHATGPT {slug}] ozet kopru: {ob['acilan']} kopru ozeti duz metne acildi")
if fixed or atilan or degisti:
body = json.dumps(req, ensure_ascii=False).encode("utf-8")
parcalar = []
if fixed: parcalar.append(f"{fixed} arac kimligi duzeltildi")
if atilan: parcalar.append(f"{atilan} yabanci oge atildi")
if parcalar: log(f"[{n} CHATGPT {slug}] " + ", ".join(parcalar))
if ozet_akisi is not None:
sonuc = self._passthrough(n, path, body, tagname=f"CHATGPT {slug}", rewritten=True, rewriter=ozet_akisi)
log(f"[{n} CHATGPT {slug}] ozet kopru: ozet {'uretildi, ' + str(len(ozet_akisi.metin)) + ' karakter' if ozet_akisi.metin else 'URETILEMEDI'}")
return sonuc
return self._passthrough(n, path, body, tagname=f"CHATGPT {slug}",
rewritten=bool(fixed or atilan or degisti))
route_name, target, flat = mapping
try:
ozet_tetik = ozet_acik and ozet_kopru.tetik_mi(req)
except Exception as e:
ozet_tetik = False; ozet_acik = False
log(f"[{n} {slug}] ozet kopru atlandi: {type(e).__name__}: {e}")
if ozet_tetik and ozet_cfg.get("model"):
# Ozeti baglami alabilen baska bir modele yazdirmak icin (ornegin kucuk hedef kendi basina ozetleyemiyorsa).
alt = MODEL_MAP.get(ozet_cfg["model"])
if alt:
route_name, target, flat = alt
# 9Router cevirisinden gecen hedefler (9Router ve AITURK IDE gecidi): ayni istek duzeltmeleri uygulanir.
via_router = route_name in (catalog.get("route", "9router"), ide_route)
route = ROUTES[route_name]
tag = f"[{n} {route_name.upper()} {slug}->{target}]"
req["model"] = target
req.pop("client_metadata", None)
for k in route.get("strip_fields", []):
req.pop(k, None)
if ozet_acik:
ob = ozet_kopru.ozetleri_ac(req, yabanci_hedef=True, coz=lambda oge: self._openai_ozet_coz(n, oge, ozet_cfg))
if ob["acilan"] or ob["cozulen"] or ob["okunamayan"]:
log(f"{tag} ozet kopru: kopru ozeti={ob['acilan']}, OpenAI ozeti duz metne cevrildi={ob['cozulen']}, okunamayan={ob['okunamayan']}")
if ozet_tetik:
OZET_FRENI.sinir = int(ozet_cfg.get("dongu_siniri", 3)); OZET_FRENI.pencere = int(ozet_cfg.get("dongu_pencere_sn", 600))
if not OZET_FRENI.izin(str(req.get("prompt_cache_key") or slug), time.time()):
log(f"{tag} ozet kopru: DONGU FRENI, ozet istegi reddedildi (ayni sohbette {OZET_FRENI.sinir} ozetten sonra)")
return self._send_json(400, {"error": {"message": ozet_kopru.DONGU_MESAJI % OZET_FRENI.sinir,
"type": "invalid_request_error", "code": "astrabridge_ozet_dongusu"}})
ozet_kopru.ozet_istegi_hazirla(req, ozet_cfg.get("istem"))
log(f"{tag} ozet kopru: sikistirma istegi duz metin ozet istegine cevrildi (girdi={len(req.get('input', []))} oge)")
rewriter = None
if flat if flat is not None else route.get("flatten_tools"):
custom = flatten_request(req)
rewriter = SseRewriter(custom)
fix_tool_ids(req)
if via_router and CFG.get('resim_sadelestir', True):
# Uretilen resim (≈3 MB) 9Router cevirisinde duz yaziya donup sohbeti 3M token'a cikariyordu (24 Eylul 2026).
cikan, kazanc = resimleri_sadelestir(req)
if cikan:
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
merged = coalesce_context(req)
if merged:
log(f"{tag} Gemini instruction context: {merged} message(s) preserved")
elif via_router and CFG.get('pano_kanca_user', True):
# 9Router Claude cevirisi developer mesajlarini dusuruyor; bu turun PANO OLAYLARI blogu (sesli_pano.py)
# sona user mesaji olarak eklenir (kanca_context.py, 24 Eylul 2026). bridge.json "pano_kanca_user": false kapatir.
eklenen, ozet = pano_blogunu_ulastir(req)
if eklenen:
log(f"{tag} PANO OLAYLARI blogu user mesaji olarak eklendi ({eklenen} karakter, {ozet})")
if via_router:
# Claude "assistant message prefill" desteklemiyor (25 Eylul 2026, sesli tur kapaninca 400).
eklendi, sonlar = claude_sonu_duzelt(req)
if eklendi:
log(f"{tag} girdi kullanici mesajiyla bitmiyordu ({sonlar}), devam mesaji eklendi")
data = json.dumps(req, ensure_ascii=False).encode("utf-8")
log(f"{tag} girdi={len(req.get('input', []))} oge, arac={len(req.get('tools', []) or [])} {len(data)} bayt")
if DEBUG:
open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data)
hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA}
last = None
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
# only failures before the work starts (no connection, 500/502/503/529) are retried.
combo = "/" not in target
for attempt in range(1, RETRIES + 1):
r = urllib.request.Request(route["url"], data=data, headers=hdrs, method="POST")
try:
resp = urllib.request.urlopen(r, timeout=UPSTREAM_IDLE_TIMEOUT)
except urllib.error.HTTPError as e:
errbody = e.read()[:400]
last = (e.code, errbody)
etxt = errbody.decode("utf-8", "replace")
# 9Router kalici ust-kaynak hatalarini (400/403 ...) 503 govdesine sararak
# donduruyor. Bunlari tekrar denemek dakikalarca bosuna bekletiyordu.
permanent = (any(p.lower() in etxt.lower() for p in NO_RETRY_PATTERNS)
or bool(re.search(r"\[4(?!29|08)\d\d\]", etxt)))
if permanent:
log(f"{tag} kalici hata, tekrar denenmiyor: HTTP {e.code} {errbody[:160]!r}")
# 524 = Cloudflare gave up waiting while the origin kept working; a retry
# re-runs (and re-bills) the whole Fusion (22 Sep 2026: 3 double runs).
elif e.code in ((500, 502, 503, 529) if combo else (500, 502, 503, 504, 529)) and attempt < RETRIES:
log(f"{tag} deneme {attempt}: HTTP {e.code} {errbody[:100]!r} -> tekrar")
time.sleep(min(2 * attempt, 8)); continue
log(f"{tag} HTTP {e.code} {errbody[:200]!r}")
self.send_response(e.code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(errbody)))
self.end_headers(); self.wfile.write(errbody); return
except Exception as e:
last = (0, str(e))
if combo and is_timeout(e):
log(f"{tag} combo zaman asimi, tekrar gonderilmiyor (calisiyor olabilir): {e}")
break
log(f"{tag} deneme {attempt}: {e}")
time.sleep(min(2 * attempt, 8)); continue
# Headers go to the client from here on: a cut stream is never resent.
if ozet_tetik:
akis = ozet_kopru.OzetAkisi(rewriter, tohum=str(n))
sonuc = self._stream(resp, resp.status, tag, akis)
log(f"{tag} ozet kopru: ozet {'uretildi, ' + str(len(akis.metin)) + ' karakter' if akis.metin else 'URETILEMEDI'}")
return sonuc
return self._stream(resp, resp.status, tag, rewriter)
self._send_json(503, {"error": {"message": f"bridge: hedef ulasilamadi {last}"}})
do_POST = _handle
do_GET = _handle
do_PUT = _handle
do_DELETE = _handle
do_PATCH = _handle
class TS(http.server.ThreadingHTTPServer):
daemon_threads = True
# Windows'ta SO_REUSEADDR baska bir surecin ayni portu "calmasina" izin verir.
# Kapali: port doluysa sessizce yan yana dinlemek yerine acikca hata verir.
allow_reuse_address = False
if __name__ == "__main__":
load_last_models()
log(f"AstraBridge :{PORT} routes={list(ROUTES)} model_map={len(MODEL_MAP)} diger->{CHATGPT}")
try:
srv = TS(("127.0.0.1", PORT), H)
except OSError as e:
log(f"HATA: 127.0.0.1:{PORT} baglanamadi ({e}). Port baska bir surecte olabilir; "
f"bridge.json icindeki 'port' degerini ve config.toml'daki openai_base_url'i degistirin.")
sys.exit(1)
srv.serve_forever()
@@ -0,0 +1,33 @@
"""Compatibility for Gemini translators that retain only the last system turn.
Only instruction-bearing text messages are folded; user/tool content is never
promoted. Caller must scope this to the affected Gemini route. No network or IO.
"""
def coalesce_context(request):
items = request.get('input')
base = request.get('instructions', '')
if not isinstance(items, list) or not isinstance(base, str):
return 0
chunks = ['[BASE SYSTEM INSTRUCTIONS]\n' + base] if base else []
remaining = []
count = 0
for item in items:
if not isinstance(item, dict) or item.get('type', 'message') != 'message' or item.get('role') not in ('system', 'developer'):
remaining.append(item)
continue
content = item.get('content', '')
if isinstance(content, str):
text = content
elif isinstance(content, list) and all(isinstance(p, dict) and p.get('type') in ('input_text','text') and isinstance(p.get('text'), str) for p in content):
text = '\n'.join(p['text'] for p in content)
else:
# Unknown multimodal instruction block: do not discard or rewrite.
return 0
chunks.append('[' + item['role'].upper() + ' CONTEXT ' + str(count+1) + ']\n' + text)
count += 1
if count:
request['instructions'] = '\n\n'.join(chunks)
request['input'] = remaining
return count
@@ -0,0 +1,150 @@
"""AITURK IDE "Codex Baglama": IDE'nin model ve combolarini Codex listesine ekler.
Kaynak, IDE'nin kullandigi gecittir (config.yaml -> providers.turkservis.base_url);
anahtar IDE'nin kendi .env dosyasindan her yenilemede okunur, bridge.json'a yazilmaz.
Ac/kapat: %LOCALAPPDATA%\\TurkServis\\AITURK-IDE\\codex-baglama.json
{"enabled": true, "kapsam": "ide"}
Dosya yoksa ACIK sayilir (kullanici karari, 9 Ekim 2026: varsayilan acik).
kapsam "ide" : IDE ayarinda tanimli modeller + adlandirilmis combolar (varsayilan)
kapsam "hepsi" : gecitteki butun sohbet modelleri (1000+; Codex listesi cok buyur)
"""
import json, os, re, urllib.request
# Agent home: HERMES_HOME when the IDE passes it, else the default install location.
AGENT = os.environ.get("HERMES_HOME") or os.path.join(os.environ.get("LOCALAPPDATA", ""), "TurkServis", "AITURK-IDE", "agent")
IDE_DIR = os.path.dirname(os.path.normpath(AGENT))
AYAR = os.path.join(IDE_DIR, "codex-baglama.json")
KEY_NAME = "HERMES_CUSTOM_TURKSERVIS_API_KEY"
DEFAULT_BASE = "https://ai.turkservis.online/v1"
def ayar_oku(yol=AYAR):
"""Dosya yoksa ya da bozuksa varsayilan: acik, kapsam 'ide'."""
ayar = {"enabled": True, "kapsam": "ide"}
try:
with open(yol, encoding="utf-8") as f:
v = json.load(f)
if isinstance(v, dict):
if isinstance(v.get("enabled"), bool):
ayar["enabled"] = v["enabled"]
if v.get("kapsam") in ("ide", "hepsi"):
ayar["kapsam"] = v["kapsam"]
except (OSError, ValueError):
pass
return ayar
def anahtar_oku(agent=AGENT):
try:
with open(os.path.join(agent, ".env"), encoding="utf-8", errors="replace") as f:
for line in f:
if line.startswith(KEY_NAME + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
def ide_yapilandirma(agent=AGENT):
"""config.yaml'dan gecit adresi, varsayilan model ve tanimli model adlari.
Kopru bagimliliksiz calistigi icin YAML'in yalniz bu bolumu satir satir okunur."""
base, varsayilan, adlar = DEFAULT_BASE, "", []
try:
with open(os.path.join(agent, "config.yaml"), encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
except OSError:
return base, varsayilan, adlar
bolum = saglayici = None
modellerde = False
for line in lines:
if not line.strip() or line.lstrip().startswith("#"):
continue
girinti = len(line) - len(line.lstrip(" "))
metin = line.strip()
if girinti == 0:
bolum = metin.rstrip(":"); saglayici = None; modellerde = False
continue
if bolum == "model" and girinti == 2:
if metin.startswith("default:"):
varsayilan = metin.split(":", 1)[1].strip().strip("'\"")
continue
if bolum != "providers":
continue
if girinti == 2:
saglayici = metin.rstrip(":"); modellerde = False
elif saglayici == "turkservis" and girinti == 4:
modellerde = metin == "models:"
if metin.startswith("base_url:"):
base = metin.split(":", 1)[1].strip().strip("'\"") or base
elif saglayici == "turkservis" and modellerde and girinti == 6:
ad = re.split(r":\s*(\{|$)", metin)[0].strip().strip("'\"")
if ad:
adlar.append(ad)
return base.rstrip("/"), varsayilan, adlar
def kisa_ad(ad):
"""Codex secicisinde gorunecek ad: yalniz kullanicinin ekledigi saglayici + model adi
(kullanici istegi, 9 Ekim 2026). "OmniRoute · " / "9Router · " gibi gecit onekleri ve aradaki
saglayici halkalari gosterilmez: "nano/qwen/qwen3.8-27b" -> "nano/qwen3.8-27b",
"OmniRoute · ag/gemini-3.8-flash" -> "ag/gemini-3.8-flash", combo adi oldugu gibi kalir."""
ad = (ad or "").split(" · ")[-1].strip()
parcalar = [p for p in ad.split("/") if p]
if len(parcalar) <= 2:
return "/".join(parcalar) or ad
return parcalar[0] + "/" + parcalar[-1]
def adlandir(girdiler, dolu=()):
"""[(kimlik, tam_ad)] -> {kimlik: gorunen_ad}. Kisa adlar cakisirsa aradaki halkalar geri konur;
yine cakisirsa kimligin sonu eklenir. `dolu`: baska kaynaktan gelen, zaten kullanilan adlar."""
kisa = {k: kisa_ad(a) for k, a in girdiler}
tam = {k: (a or "").split(" · ")[-1].strip() for k, a in girdiler}
sayac = {}
for v in list(kisa.values()) + list(dolu):
sayac[v.lower()] = sayac.get(v.lower(), 0) + 1
sonuc, kullanilan = {}, {d.lower() for d in dolu}
for k, _ in girdiler:
ad = kisa[k] if sayac[kisa[k].lower()] == 1 else tam[k]
if ad.lower() in kullanilan:
ad = f"{ad} [{k[-8:]}]"
kullanilan.add(ad.lower())
sonuc[k] = ad
return sonuc
def sec(data, tanimli, kapsam="ide"):
"""Gecit katalogundan Codex'e eklenecek girdiler: [{'id','ad'}], sirali ve tekrarsiz.
Yalniz sohbet modelleri; resim/ses/video/embedding uretenler eklenmez."""
tanimli = {a.lower() for a in tanimli if a}
secilen, gorulen = [], set()
for m in data:
mid = m.get("id") or ""
ts = m.get("turkservis") or {}
if not mid or mid in gorulen or "chat" not in (ts.get("capabilities") or ["chat"]):
continue
gorunen = ts.get("display_name") or mid
kisa = gorunen.split(" · ")[-1].strip()
combo = "/" not in kisa # saglayici/model bicimi degil: adlandirilmis combo ya da takma ad
if kapsam != "hepsi" and not combo and kisa.lower() not in tanimli and mid.lower() not in tanimli:
continue
gorulen.add(mid)
secilen.append({"id": mid, "ad": gorunen, "sira": ts.get("order") or 0})
secilen.sort(key=lambda s: (s["sira"], s["id"]))
return [{"id": s["id"], "ad": s["ad"]} for s in secilen]
def yukle(ua="AstraBridge/1.0 (codex)", timeout=30):
"""Donus: (durum, base_url, anahtar, girdiler). durum: 'kapali' | 'anahtar-yok' | 'tamam'.
Ag hatasinda istisna atar; cagiran son listeyi korur."""
ayar = ayar_oku()
if not ayar["enabled"]:
return "kapali", "", "", []
anahtar = anahtar_oku()
if not anahtar:
return "anahtar-yok", "", "", []
base, varsayilan, adlar = ide_yapilandirma()
req = urllib.request.Request(base + "/models", headers={"Authorization": f"Bearer {anahtar}", "User-Agent": ua})
data = json.load(urllib.request.urlopen(req, timeout=timeout)).get("data", [])
return "tamam", base, anahtar, sec(data, adlar + [varsayilan], ayar["kapsam"])
@@ -0,0 +1,269 @@
"""Sikistirma (ozet) bagdastiricisi (6 Ekim 2026, kart a-grubu-bakim-20261006-0417).
Codex 0.160.0 "remote compaction v2" kullanir: girdinin sonuna {"type": "compaction_trigger"} koyar ve yanitta tam bir
{"type": "compaction", "encrypted_content": ...} ogesi bekler. Gercek olcum (raporlar/sesli-kopru-kanit-20261006/sik1, sik2):
* 9Router modelleri bu ogeyi uretemez -> "remote compaction v2 expected exactly one compaction output item, got 0",
tur hata verir (kucuk modele geciste ve 9Router modelinde esik asilinca).
* OpenAI'nin urettigi sifreli ozet 9Router modelince okunamaz -> kararlar/gerekceler kaybolur.
Bu modul:
1. 9Router'a giden sikistirma isteginde ozeti duz metin olarak ayni (ya da bridge.json ozet_kopru.model ile secilen)
modele yazdirir ve Codex'in bekledigi "compaction" ogesine sarar (encrypted_content = ISARET + base64(metin)).
2. Koprunun urettigi ozet ogelerini her giden istekte duz metin kullanici mesajina acar (hangi saglayiciya giderse).
3. OpenAI'nin sifreli ozeti 9Router modeline gidecekse, OpenAI'ye bir kez "bu ozeti duz metne dok" diye sorar,
sonucu diske onbellekler ve onu gonderir; olmazsa durust bir not koyar (sessiz kayip yok).
OpenAI -> OpenAI sikistirmasi oldugu gibi (yerlesik) kalir.
Kapatmak: bridge.json "ozet_kopru": {"enabled": false}
"""
import base64, hashlib, json, os, re
ISARET = "ASTRABRIDGE-OZET-1:"
OZET_BASLIK = "<onceki_konusma_ozeti>"
OZET_BITIS = "</onceki_konusma_ozeti>"
OZET_ISTEMI = """BAGLAM KONTROL NOKTASI (bu mesaj kullanicidan degil, kopru altyapisindan): Bu konusma baglam sinirina yaklastigi icin ozetlenip baska bir modele devredilecek. Simdi arac CAGIRMA; yalniz asagidaki ozeti yaz. Ozeti okuyacak model bu konusmayi goremeyecek, yalniz senin yazdigini gorecek; bu yuzden kisa tutmak icin bilgi atma. Bu ozet istegini kullanicinin istegi gibi ozetleme.
Kurallar:
- Kullanicinin ve konusmanin kullandigi etiket, kimlik ve adlari (ornek: "KARAR-1", kart/is numaralari, dosya adlari, model adlari) HARFI HARFINE koru ve her bilgiyi kendi etiketiyle yaz.
- Sayilari, tarihleri, yollari, komutlari ve onemli ozgun cumleleri degistirmeden aktar.
- Uydurma; emin olmadigin seyi "dogrulanmadi" diye isaretle. Sifre, anahtar ve gizli degerleri yazma.
Su basliklarla, madde madde ve somut yaz:
1. KULLANICININ ISTEGI VE HEDEF: kullanicinin kendi sozleriyle ne istedigi, kabul olcutleri, kisitlar.
2. KARARLAR VE GEREKCELERI: verilen her karar ve neden verildigi (etiketleriyle); reddedilen secenekler ve nedeni.
3. YAPILANLAR VE ONEMLI SONUCLAR: degisen dosyalar, calistirilan komutlar ve gercek ciktilari, test/olcum sonuclari, dogrulanan ve dogrulanamayan seyler.
4. ACIK ISLER VE SIRADAKI ADIM: bitmemis isler, bekleyen sorular/onaylar, bir sonraki somut adim.
5. KULLANICI TERCIHLERI VE UYARILAR: kalici talimatlar, yapilmamasi istenenler, bilinen tuzaklar ve hatalar.
6. GEREKLI BASVURULAR: yollar, adresler, kart/is numaralari, model ve ayar adlari."""
COZ_ISTEMI = ("Yukaridaki sikistirilmis konusma ozetinin icerigini, bu konusmayi hic gormemis baska bir modelin isi "
"surdurebilmesi icin eksiksiz ve ayrintili DUZ METIN olarak yaz: kullanicinin istegi, kararlar ve gerekceleri, "
"yapilanlar ve onemli sonuclar, acik isler ve siradaki adim, kullanici tercihleri, gerekli yollar/kimlikler. "
"Arac cagirma, yorum ekleme, uydurma. Sifre ve anahtar yazma.")
OKUNAMADI_NOTU = ("(Kopru notu: Bu noktadan onceki konusma, onceki modelin sifreli ozetinde duruyor ve bu modele "
"aktarilamadi. Onceki kararlari bilmiyorsan uydurma; kullaniciya sor.)")
def tetik_mi(req):
"""Istek bir sikistirma istegi mi (girdinin son ogesi compaction_trigger)?"""
ogeler = req.get("input")
return (isinstance(ogeler, list) and bool(ogeler) and isinstance(ogeler[-1], dict)
and ogeler[-1].get("type") == "compaction_trigger")
def ozet_ogesi(metin, kimlik_tohumu=""):
"""Duz metin ozeti Codex'in bekledigi compaction ogesine sarar."""
kimlik = "cmp_astrabridge_" + hashlib.sha256((kimlik_tohumu + metin).encode("utf-8")).hexdigest()[:32]
return {"type": "compaction", "id": kimlik,
"encrypted_content": ISARET + base64.b64encode(metin.encode("utf-8")).decode("ascii")}
def ozet_metni(oge):
"""Koprunun urettigi compaction ogesinden duz metni cikarir; kopru ogesi degilse None."""
icerik = oge.get("encrypted_content") if isinstance(oge, dict) else None
if not isinstance(icerik, str) or not icerik.startswith(ISARET):
return None
try:
return base64.b64decode(icerik[len(ISARET):]).decode("utf-8")
except Exception:
return None
def _ozet_mesaji(metin):
return {"type": "message", "role": "user",
"content": [{"type": "input_text", "text": OZET_BASLIK + "\n" + metin.strip() + "\n" + OZET_BITIS}]}
def ozetleri_ac(req, yabanci_hedef=False, coz=None):
"""Giden istekteki compaction ogelerini hedefin okuyabilecegi bicime getirir (yerinde).
- Koprunun urettigi ozet -> duz metin kullanici mesaji (her hedefte).
- Baska saglayicinin (OpenAI) sifreli ozeti: hedef OpenAI ise dokunulmaz; hedef yabanciysa (9Router) coz(oge)
ile duz metne cevrilir, olmazsa OKUNAMADI_NOTU konur.
Doner: {'acilan': n, 'cozulen': n, 'okunamayan': n}"""
bilgi = {"acilan": 0, "cozulen": 0, "okunamayan": 0}
ogeler = req.get("input")
if not isinstance(ogeler, list):
return bilgi
yeni = []
for oge in ogeler:
if isinstance(oge, dict) and oge.get("type") == "compaction":
metin = ozet_metni(oge)
if metin is not None:
yeni.append(_ozet_mesaji(metin)); bilgi["acilan"] += 1
continue
if yabanci_hedef:
cozulmus = None
if coz is not None:
try:
cozulmus = coz(oge)
except Exception:
cozulmus = None
if cozulmus:
yeni.append(_ozet_mesaji(cozulmus)); bilgi["cozulen"] += 1
else:
yeni.append({"type": "message", "role": "user",
"content": [{"type": "input_text", "text": OKUNAMADI_NOTU}]}); bilgi["okunamayan"] += 1
continue
yeni.append(oge)
if bilgi["acilan"] or bilgi["cozulen"] or bilgi["okunamayan"]:
req["input"] = yeni
return bilgi
def ozet_istegi_hazirla(req, istem=None):
"""Sikistirma istegini (sonunda compaction_trigger) duz metin ozet istegine cevirir (yerinde)."""
ogeler = [o for o in req.get("input") or [] if not (isinstance(o, dict) and o.get("type") == "compaction_trigger")]
ogeler.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": istem or OZET_ISTEMI}]})
req["input"] = ogeler
req["tool_choice"] = "none"
req["stream"] = True
return req
def _olay(d):
return b"event: " + d["type"].encode() + b"\ndata: " + json.dumps(d, ensure_ascii=False).encode("utf-8") + b"\n\n"
class OzetAkisi:
"""Ozet isteginin SSE yanitini Codex'in bekledigi tek 'compaction' ogesine cevirir.
ic: once calisan cevirici (SseRewriter). Metin gelmezse compaction ogesi uretmez (Codex hatayi gosterir)."""
def __init__(self, ic=None, tohum=""):
self.ic = ic
self.tohum = tohum
self.buf = b""
self.parcalar = {} # item_id -> delta metni
self.bitenler = [] # tamamlanan mesaj metinleri
self.metin = None # sonuc (gunluk/test icin)
self.bitti = False
def _topla(self):
metin = "\n\n".join(t for t in self.bitenler if t.strip())
if not metin.strip():
metin = "\n\n".join(t for t in self.parcalar.values() if t.strip())
return metin.strip()
def _blok(self, blok):
veri = [s[5:].strip() for s in re.split(rb"\r?\n", blok) if s.startswith(b"data:")]
if not veri:
return b""
try:
d = json.loads(b"\n".join(veri))
except Exception:
return b""
if not isinstance(d, dict):
return b""
t = d.get("type", "")
if t == "response.created":
return _olay(d)
if t == "response.output_text.delta":
self.parcalar[d.get("item_id")] = self.parcalar.get(d.get("item_id"), "") + (d.get("delta") or "")
return b": ozet\n\n" # baglantiyi canli tutar; Codex yorum satirini yok sayar
if t == "response.output_item.done":
oge = d.get("item") or {}
if oge.get("type") == "message":
self.bitenler.append("".join(p.get("text", "") for p in oge.get("content") or []
if isinstance(p, dict) and p.get("type") == "output_text"))
return b": ozet\n\n"
if t == "response.completed":
self.metin = self._topla()
self.bitti = True
if not self.metin:
return _olay(d) # ozet yok: Codex "0 compaction ogesi" hatasini gosterir
oge = ozet_ogesi(self.metin, self.tohum)
yanit = dict(d.get("response") or {})
yanit["output"] = [oge]
return (_olay({"type": "response.output_item.done", "output_index": 0, "item": oge})
+ _olay({"type": "response.completed", "response": yanit}))
if t in ("response.failed", "response.incomplete", "error"):
return _olay(d)
return b""
def feed(self, chunk):
if self.ic:
chunk = self.ic.feed(chunk)
self.buf += chunk
out = b""
while True:
m = re.search(rb"\r?\n\r?\n", self.buf)
if not m:
break
blok, self.buf = self.buf[:m.start()], self.buf[m.end():]
out += self._blok(blok)
return out
def flush(self):
if self.ic:
self.buf += self.ic.flush()
self.buf = b""
return b""
# ---- OpenAI'nin sifreli ozetini duz metne cevirme (onbellekli) ----
def onbellek_yolu(klasor, oge):
kimlik = str(oge.get("id") or hashlib.sha256(str(oge.get("encrypted_content")).encode()).hexdigest()[:32])
return os.path.join(klasor, re.sub(r"[^A-Za-z0-9_.-]", "_", kimlik)[:80] + ".txt")
def onbellekten(klasor, oge):
try:
with open(onbellek_yolu(klasor, oge), encoding="utf-8") as f:
return f.read().strip() or None
except OSError:
return None
def onbellege(klasor, oge, metin):
os.makedirs(klasor, exist_ok=True)
yol = onbellek_yolu(klasor, oge)
with open(yol + ".tmp", "w", encoding="utf-8") as f:
f.write(metin)
os.replace(yol + ".tmp", yol)
def coz_istegi(oge, model, talimat="Sen bir konusma ozeti aktaricisisin."):
"""Sifreli ozeti duz metne dokturmek icin OpenAI'ye gidecek istek govdesi."""
temiz = {k: v for k, v in oge.items() if k in ("type", "id", "encrypted_content")}
return {"model": model, "instructions": talimat, "store": False, "stream": True, "tools": [],
"tool_choice": "none", "parallel_tool_calls": False,
"reasoning": {"effort": "low", "context": "all_turns"}, # chatgpt.com arka ucu bunu sart kosuyor
"include": ["reasoning.encrypted_content"],
"input": [temiz, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": COZ_ISTEMI}]}]}
def sse_metni(govde):
"""SSE govdesinden (bayt) asistan metnini toplar."""
a = OzetAkisi()
a.feed(govde if govde.endswith(b"\n\n") else govde + b"\n\n")
return a.metin if a.bitti else a._topla()
# ---- Dongu freni ----
# Ozetten sonra da baglam hedefin esigini asiyorsa (Codex son kullanici mesajlarini aynen tuttugu icin olabilir) Codex
# ayni turda tekrar tekrar sikistirma ister; her biri basarili oldugu icin dongu kendiliginden bitmez (olcum: sik6).
DONGU_MESAJI = ("AstraBridge: Bu sohbetin baglami, ozetlendikten sonra da secili modelin sinirina sigmiyor "
"(kisa surede %d ozet denendi). Dongu durduruldu. Daha buyuk baglam pencereli bir model secin ya da "
"yeni sohbet acin; mesajlariniz sohbette duruyor.")
class DonguFreni:
"""Ayni sohbet icin kisa surede cok sayida ozet istegini sayar."""
def __init__(self, sinir=3, pencere_sn=600):
self.sinir = sinir
self.pencere = pencere_sn
self.kayit = {}
def izin(self, anahtar, simdi):
"""Bu ozet istegine izin var mi? Varsa kaydeder."""
zamanlar = [t for t in self.kayit.get(anahtar, []) if simdi - t < self.pencere]
if len(zamanlar) >= self.sinir:
self.kayit[anahtar] = zamanlar
return False
zamanlar.append(simdi)
self.kayit[anahtar] = zamanlar
if len(self.kayit) > 500: # bellek sismesin
for k in sorted(self.kayit, key=lambda k: self.kayit[k][-1])[:250]:
self.kayit.pop(k, None)
return True
@@ -0,0 +1,94 @@
"""9Router'a giden isteklerde resim, video, ses, PDF ve dev içerikleri sadeleştirir (24 Eylül 2026, kullanıcı onayı).
Sorun: Codex, image_gen aracının ürettiği resmi (≈3 MB base64 PNG) araç çıktısı olarak sohbete geri koyuyor.
9Router'ın Claude çevirisi bunu düz yazı sayıyor: istek 3 milyon token'a çıktı ("prompt is too long:
3040251 tokens > 1000000 maximum") ve sohbetteki sonraki HER tur 400 ile düştü; sohbet kurtarılamadı.
Aynısı video, ses, PDF/dosya ve çok büyük araç çıktılarıyla da olabilir (kullanıcı isteği: "tekrar böyle sorun olmasın").
Kural:
- Medya parçaları (resim, dosya/PDF, ses, video): araç çıktılarında ve eski mesajlarda kısa notla değiştirilir.
Kullanıcının SON mesajındaki medya toplamı TUT_BAYT'ı aşmıyorsa korunur (yeni eklenen dosyayı model görebilsin).
- Tür ne olursa olsun tek başına DEV_BAYT'tan büyük her parça (son mesajdaki korunanlar hariç) notla değiştirilir.
- Düz yazı araç çıktısı DEV_BAYT'tan büyükse başı ve sonu kalır, ortası notla kısaltılır.
Codex'in kendi kaydına dokunulmaz; yalnız 9Router'a giden kopya değişir.
"""
import json
TUT_BAYT = 1_000_000
DEV_BAYT = 1_000_000
RESIM_TUT_BAYT = TUT_BAYT # eski ad
MEDYA_TURLERI = {'input_image': 'resim', 'image': 'resim', 'image_url': 'resim',
'input_file': 'dosya/PDF', 'file': 'dosya/PDF',
'input_audio': 'ses', 'audio': 'ses',
'input_video': 'video', 'video': 'video'}
def _boyut(p):
try:
return len(json.dumps(p, ensure_ascii=False))
except (TypeError, ValueError):
return len(str(p))
def _medya_mi(p):
return isinstance(p, dict) and p.get('type') in MEDYA_TURLERI
def _not(p):
tur = MEDYA_TURLERI.get(p.get('type'), p.get('type') or 'içerik') if isinstance(p, dict) else 'içerik'
ad = p.get('filename') or p.get('file_path') or p.get('path') or '' if isinstance(p, dict) else ''
ek = f' ({ad})' if ad else ''
return {'type': 'input_text',
'text': f'[{tur}{ek} çıkarıldı (AstraBridge): bu yoldan gönderilemiyor, ~{_boyut(p) // 1024} KB; '
'gerekirse dosyayı yolundan aç]'}
def _kisalt(metin):
yarim = DEV_BAYT // 4
return (metin[:yarim] + f'\n[... {len(metin) - 2 * yarim} karakter çıkarıldı (AstraBridge): çıktı çok büyüktü ...]\n'
+ metin[-yarim:])
def resimleri_sadelestir(req, tut_bayt=TUT_BAYT):
"""(değiştirilen parça sayısı, kazanılan bayt). req['input'] yerinde değişir."""
inp = req.get('input')
if not isinstance(inp, list):
return 0, 0
son_kullanici = None
for i, it in enumerate(inp):
if isinstance(it, dict) and it.get('type') == 'message' and it.get('role') == 'user':
son_kullanici = i
koru = set()
if son_kullanici is not None:
icerik = inp[son_kullanici].get('content')
if isinstance(icerik, list):
medya = [p for p in icerik if _medya_mi(p)]
if medya and sum(_boyut(p) for p in medya) <= tut_bayt:
koru.add(son_kullanici)
sayi = kazanc = 0
for i, it in enumerate(inp):
if not isinstance(it, dict) or i in koru:
continue
for alan in ('content', 'output'):
deger = it.get(alan)
if isinstance(deger, str) and len(deger) > DEV_BAYT:
yeni = _kisalt(deger)
kazanc += len(deger) - len(yeni)
sayi += 1
it[alan] = yeni
continue
if not isinstance(deger, list):
continue
degisti = False
yeni_liste = []
for p in deger:
if _medya_mi(p) or _boyut(p) > DEV_BAYT:
kazanc += _boyut(p)
sayi += 1
degisti = True
yeni_liste.append(_not(p))
else:
yeni_liste.append(p)
if degisti:
it[alan] = yeni_liste
return sayi, kazanc
@@ -0,0 +1,83 @@
"""Regression coverage for Codex -> external API access_programs compatibility."""
import ast
import copy
import json
from pathlib import Path
import unittest
from unittest.mock import Mock
import urllib.request
# Load only the actual adapter and routing method: no catalog/network/thread
# startup or credential loading is needed to exercise the production code.
source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
flatten = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "flatten_request")
claude_sonu = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt")
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
class AccessPrograms(unittest.TestCase):
def setUp(self):
self.upstream = Mock(return_value=Mock(status=200))
self.env = {
"json": json,
"urllib": Mock(request=Mock(Request=urllib.request.Request, urlopen=self.upstream)),
"decompress": lambda body, encoding: body,
"MODEL_MAP": {},
"CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"},
"resim_sadelestir": False, "pano_kanca_user": False},
"ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}},
"fix_tool_ids": lambda req: 0,
"drop_foreign_items": lambda req: 0,
"SseRewriter": Mock(),
"log": lambda message: None,
"DEBUG": False, "UA": "test", "RETRIES": 1, "UPSTREAM_IDLE_TIMEOUT": 3900,
}
exec(compile(ast.Module(body=[flatten, claude_sonu, responses], type_ignores=[]), "bridge.py", "exec"), self.env)
def request(self, model):
return {"model": model, "access_programs": {"cyber": "standard"},
"metadata": {"access_programs": "preserve nested data"},
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
"tools": [{"type": "function", "name": "echo", "parameters": {"type": "object", "properties": {}}}]}
def test_adapter_removes_only_top_level_field(self):
req = self.request("meta/muse-spark-1.3")
original = copy.deepcopy(req)
self.env["flatten_request"](req)
self.assertNotIn("access_programs", req)
self.assertEqual(req["metadata"], original["metadata"])
self.assertEqual(req["input"], original["input"])
self.assertEqual(req["tools"][0]["name"], "echo")
def test_absent_null_and_empty_values(self):
for value in (None, {}, {"cyber": "standard"}):
with self.subTest(value=value):
req = {"input": [], "access_programs": value}
self.env["flatten_request"](req)
self.assertNotIn("access_programs", req)
self.env["flatten_request"]({"input": []})
def test_model_and_combo_external_request_strips_field(self):
for model in ("9r/meta/muse-spark-1.3-contributor", "9r/Fusion-Hizli"):
with self.subTest(model=model):
body = json.dumps(self.request(model)).encode()
fake = Mock(headers={})
self.env["_responses"](fake, 1, "/responses", body)
sent = json.loads(self.upstream.call_args.args[0].data)
self.assertNotIn("access_programs", sent)
self.assertEqual(sent["model"], model.removeprefix("9r/"))
self.assertIn("access_programs", json.loads(body))
fake._stream.assert_called_once()
def test_chatgpt_passthrough_preserves_original_body(self):
body = json.dumps(self.request("gpt-6-astra")).encode()
fake = Mock(headers={})
self.env["_responses"](fake, 1, "/responses", body)
self.assertEqual(fake._passthrough.call_args.args[2], body)
self.upstream.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,45 @@
"""claude_sonu_duzelt testleri (9Router Claude prefill 400)."""
import ast
import json
from pathlib import Path
import unittest
tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
env = {}
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt"], type_ignores=[]), "bridge.py", "exec"), env)
f = env["claude_sonu_duzelt"]
def msg(role, text="x"):
return {"type": "message", "role": role, "content": [{"type": "input_text", "text": text}]}
class ClaudeSonu(unittest.TestCase):
def test_asistanla_biten_duzelir(self):
req = {"input": [msg("user"), msg("assistant")]}
self.assertTrue(f(req)[0])
self.assertEqual(req["input"][-1]["role"], "user")
def test_asistan_sonra_developer_duzelir(self):
# Sesli tur kapaninca: ... assistant, developer (9Router developer'i dusurur -> asistanla biter)
req = {"input": [msg("user"), msg("assistant"), msg("developer")]}
self.assertTrue(f(req)[0])
self.assertEqual(req["input"][-1]["role"], "user")
def test_reasoning_ile_biten_duzelir(self):
req = {"input": [msg("user"), {"type": "reasoning", "id": "rs_1"}]}
self.assertTrue(f(req)[0])
def test_kullaniciyla_biten_degismez(self):
req = {"input": [msg("assistant"), msg("user"), msg("developer")]}
self.assertFalse(f(req)[0])
self.assertEqual(len(req["input"]), 3)
def test_arac_sonucuyla_biten_degismez(self):
req = {"input": [msg("user"), {"type": "custom_tool_call", "call_id": "c"}, {"type": "custom_tool_call_output", "call_id": "c"}]}
self.assertFalse(f(req)[0])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,96 @@
"""A 9Router combo (Fusion-*) must not be sent twice once it may be running upstream.
22 Sep 2026: after a Cloudflare 524 the bridge resent 9r/Fusion-Orta three times and the whole
Fusion ran (and billed) again. Failures before the work starts may still be retried.
"""
import ast
import io
import json
from pathlib import Path
import types
import unittest
from unittest.mock import Mock
import urllib.error
import urllib.request
source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
pick = lambda name: next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == name)
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
def http_error(code, body=b"{}"):
return urllib.error.HTTPError("https://example.invalid", code, "x", {}, io.BytesIO(body))
class ComboNotResent(unittest.TestCase):
def setUp(self):
self.upstream = Mock()
self.env = {
"json": json, "re": __import__("re"), "os": __import__("os"),
"time": types.SimpleNamespace(sleep=lambda s: None),
"urllib": types.SimpleNamespace(
request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=self.upstream),
error=urllib.error),
"decompress": lambda body, encoding: body,
"MODEL_MAP": {},
"CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"},
"resim_sadelestir": False, "pano_kanca_user": False},
"ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": False}},
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0,
"SseRewriter": Mock(), "log": lambda message: None,
"DEBUG": False, "UA": "test", "RETRIES": 6, "UPSTREAM_IDLE_TIMEOUT": 3600,
"NO_RETRY_PATTERNS": [],
}
exec(compile(ast.Module(body=[pick("flatten_request"), pick("claude_sonu_duzelt"), pick("is_timeout"), responses],
type_ignores=[]), "bridge.py", "exec"), self.env)
def send(self, model):
body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode()
fake = Mock(headers={})
self.env["_responses"](fake, 1, "/responses", body)
return fake
def test_combo_timeout_is_not_resent(self):
self.upstream.side_effect = urllib.error.URLError(TimeoutError("timed out"))
fake = self.send("9r/Fusion-Orta")
self.assertEqual(self.upstream.call_count, 1)
fake._stream.assert_not_called()
def test_combo_504_and_524_are_not_resent(self):
for code in (504, 524):
with self.subTest(code=code):
self.upstream.reset_mock()
self.upstream.side_effect = http_error(code)
self.send("9r/Fusion-Agir")
self.assertEqual(self.upstream.call_count, 1)
def test_combo_cut_mid_stream_is_not_resent(self):
self.upstream.side_effect = None
self.upstream.return_value = Mock(status=200)
fake = self.send("9r/Fusion-Orta")
fake._stream.side_effect = TimeoutError("read timed out")
self.upstream.reset_mock()
with self.assertRaises(TimeoutError):
self.send_with(fake, "9r/Fusion-Orta")
self.assertEqual(self.upstream.call_count, 1)
def send_with(self, fake, model):
body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode()
self.env["_responses"](fake, 1, "/responses", body)
def test_combo_failure_before_work_starts_is_retried(self):
self.upstream.side_effect = [http_error(503), urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)]
fake = self.send("9r/Fusion-Orta")
self.assertEqual(self.upstream.call_count, 3)
fake._stream.assert_called_once()
def test_single_model_timeout_is_still_retried(self):
self.upstream.side_effect = [urllib.error.URLError(TimeoutError("timed out")), Mock(status=200)]
fake = self.send("9r/cc/claude-opus-5-5")
self.assertEqual(self.upstream.call_count, 2)
fake._stream.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
import json, os, tempfile, unittest
import ide_catalog as c
def girdi(mid, ad, yetenek=("chat",), sira=0):
return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira}}
KATALOG = [
girdi("Ajan-Arac", "OmniRoute · Ajan-Arac", sira=5),
girdi("9router-cc-opus", "OmniRoute · cc/claude-opus-5-5", sira=2),
girdi("9router-nano-x", "OmniRoute · nano/x", sira=1),
girdi("gorsel", "OmniRoute · gpt-image-1", ("image",)),
girdi("9router-Takim-Agir", "OmniRoute · Takim-Agir", sira=9),
girdi("9router-Takim-Agir-6e65dba5", "OmniRoute · Takim-Agir", sira=8),
girdi("Ajan-Arac", "OmniRoute · Ajan-Arac (tekrar)"),
]
class Ayar(unittest.TestCase):
def test_dosya_yoksa_varsayilan_acik(self):
self.assertEqual(c.ayar_oku(os.path.join(tempfile.gettempdir(), "yok-boyle-dosya.json")), {"enabled": True, "kapsam": "ide"})
def test_kapatma_ve_bozuk_dosya(self):
with tempfile.TemporaryDirectory() as d:
yol = os.path.join(d, "a.json")
for icerik, beklenen in [('{"enabled": false}', {"enabled": False, "kapsam": "ide"}),
('{"enabled": true, "kapsam": "hepsi"}', {"enabled": True, "kapsam": "hepsi"}),
('{"enabled": "hayir", "kapsam": "x"}', {"enabled": True, "kapsam": "ide"}),
('bozuk', {"enabled": True, "kapsam": "ide"})]:
open(yol, "w", encoding="utf-8").write(icerik)
self.assertEqual(c.ayar_oku(yol), beklenen, icerik)
class Yapilandirma(unittest.TestCase):
def test_config_yaml_okuma(self):
with tempfile.TemporaryDirectory() as d:
open(os.path.join(d, "config.yaml"), "w", encoding="utf-8").write(
"model:\n default: Ajan-Arac\n provider: turkservis\nproviders:\n baska:\n models:\n Yabanci: {}\n"
" turkservis:\n base_url: https://ornek.test/v1/\n # yorum\n models:\n Hermes-Master: {}\n"
" 'cc/claude-opus-5-5': {}\n timeout: 3\nmcp_servers:\n x:\n models:\n Degil: {}\n")
open(os.path.join(d, ".env"), "w", encoding="utf-8").write("A=1\nHERMES_CUSTOM_TURKSERVIS_API_KEY='sahte-anahtar'\n")
self.assertEqual(c.ide_yapilandirma(d), ("https://ornek.test/v1", "Ajan-Arac", ["Hermes-Master", "cc/claude-opus-5-5"]))
self.assertEqual(c.anahtar_oku(d), "sahte-anahtar")
def test_dosyalar_yoksa(self):
with tempfile.TemporaryDirectory() as d:
self.assertEqual(c.ide_yapilandirma(d), (c.DEFAULT_BASE, "", []))
self.assertEqual(c.anahtar_oku(d), "")
class Secim(unittest.TestCase):
def test_ide_kapsami(self):
s = c.sec(KATALOG, ["cc/claude-opus-5-5"], "ide")
# Tanimli model + adlandirilmis combolar; tanimsiz saglayici modeli ve gorsel modeli yok.
self.assertEqual([x["id"] for x in s], ["9router-cc-opus", "Ajan-Arac", "9router-Takim-Agir-6e65dba5", "9router-Takim-Agir"])
def test_gorunen_ad_yalniz_saglayici_ve_model(self):
for tam, beklenen in [("OmniRoute · ag/gemini-3.8-flash", "ag/gemini-3.8-flash"),
("nano/qwen/qwen3.8-27b-obliterated:thinking", "nano/qwen3.8-27b-obliterated:thinking"),
("9Router · Nous-Research/inclusionai/ling-3.1-flash", "Nous-Research/ling-3.1-flash"),
("cf/@cf/qwen/qwq-32b", "cf/qwq-32b"),
("OmniRoute · Ajan-Arac", "Ajan-Arac"), ("cc/claude-opus-5-5", "cc/claude-opus-5-5")]:
self.assertEqual(c.kisa_ad(tam), beklenen, tam)
def test_cakisan_adlar_ayirt_edilir(self):
adlar = c.adlandir([("a", "nano/z-ai/glm-5.3"), ("b", "nano/baska/glm-5.3"), ("d", "cc/opus"), ("e-12345678", "OmniRoute · Takim-Agir")],
dolu={"takim-agir"})
self.assertEqual(adlar["a"], "nano/z-ai/glm-5.3") # kisa adi cakisan: aradaki halka geri gelir
self.assertEqual(adlar["b"], "nano/baska/glm-5.3")
self.assertEqual(adlar["d"], "cc/opus")
self.assertEqual(adlar["e-12345678"], "Takim-Agir [12345678]")
self.assertEqual(len(set(adlar.values())), 4)
def test_hepsi_kapsami_yalniz_sohbet(self):
s = c.sec(KATALOG, [], "hepsi")
self.assertEqual(len(s), 5)
self.assertNotIn("gorsel", [x["id"] for x in s])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
"""Provider-switch history regression tests, without startup or network access."""
import ast
import copy
import json
from pathlib import Path
import unittest
tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
names = {"flatten_request", "fix_tool_ids", "drop_foreign_items", "SseRewriter"}
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
env = {"json": json, "log": lambda message: None}
for node in tree.body:
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES"} for t in node.targets):
nodes.insert(0, node)
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
class ModelSwitch(unittest.TestCase):
def test_external_to_chatgpt_preserves_messages_and_paired_tool_calls(self):
req = {"input": [
{"type": "reasoning", "id": "rs_native", "encrypted_content": "native-reasoning"},
{"type": "reasoning", "id": "rs_chatcmpl-foreign"},
{"type": "item_reference", "id": "chatcmpl-foreign"},
{"type": "message", "id": "msg_chatcmpl-foreign", "role": "assistant", "content": "remember-4827"},
{"type": "custom_tool_call", "id": "fc_chatcmpl-call", "call_id": "call_4827", "name": "probe", "input": "hello"},
{"type": "custom_tool_call_output", "id": "fc_chatcmpl-output", "call_id": "call_4827", "output": "result-4827"},
]}
self.assertEqual(env["fix_tool_ids"](req), 2)
self.assertEqual(env["drop_foreign_items"](req), 2)
self.assertEqual(req["input"][0]["encrypted_content"], "native-reasoning")
self.assertEqual(req["input"][1]["content"], "remember-4827")
self.assertEqual(req["input"][2]["id"], "ctc_chatcmpl-call")
self.assertEqual(req["input"][3]["id"], "ctco_chatcmpl-output")
self.assertEqual(req["input"][2]["call_id"], req["input"][3]["call_id"])
self.assertEqual(req["input"][3]["output"], "result-4827")
def test_chatgpt_to_external_preserves_custom_tool_history(self):
original = {"access_programs": {"cyber": "standard"}, "input": [
{"type": "custom_tool_call", "id": "ctc_4827", "call_id": "call_4827", "name": "probe", "input": "remember-4827"},
{"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"},
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
req = copy.deepcopy(original)
self.assertEqual(env["flatten_request"](req), {"probe"})
env["fix_tool_ids"](req)
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"})
self.assertEqual(req["input"][1]["output"], "result-4827")
self.assertEqual(req["input"][0]["call_id"], req["input"][1]["call_id"])
self.assertEqual(req["input"][0]["type"], "function_call")
self.assertEqual(req["input"][1]["type"], "function_call_output")
self.assertIn("access_programs", original)
def test_streamed_external_custom_tool_can_return_to_codex(self):
event = {"type": "response.output_item.done", "item": {
"type": "function_call", "id": "fc_chatcmpl-probe", "call_id": "call_4827",
"name": "probe", "arguments": json.dumps({"input": "remember-4827"})}}
raw = ("event: response.output_item.done\ndata: " + json.dumps(event) + "\n\n").encode()
adapter = env["SseRewriter"]({"probe"})
out = adapter.feed(raw[:25]) + adapter.feed(raw[25:]) + adapter.flush()
rewritten = json.loads(next(line[6:] for line in out.decode().splitlines() if line.startswith("data: ")))
self.assertEqual(rewritten["item"]["type"], "custom_tool_call")
self.assertEqual(rewritten["item"]["input"], "remember-4827")
self.assertEqual(rewritten["item"]["call_id"], "call_4827")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,145 @@
"""ozet_kopru testleri: 9Router icin sikistirma bagdastiricisi ve saglayicilar arasi ozet aktarimi."""
import json
import os
import tempfile
import unittest
import ozet_kopru as o
def msg(rol, metin):
return {"type": "message", "role": rol, "content": [{"type": "input_text", "text": metin}]}
def sse(*olaylar):
return b"".join(b"event: " + e["type"].encode() + b"\ndata: " + json.dumps(e, ensure_ascii=False).encode() + b"\n\n" for e in olaylar)
def coz(veri):
return [json.loads(s[5:]) for blok in veri.split(b"\n\n") for s in blok.split(b"\n") if s.startswith(b"data:")]
OPENAI_OZETI = {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte", "internal_chat_message_metadata_passthrough": {"turn_id": "t"}}
class Istek(unittest.TestCase):
def test_tetik_algilanir_ve_ozet_istegine_cevrilir(self):
req = {"model": "m", "tools": [{"type": "function", "name": "x"}], "input": [msg("user", "KARAR-1: 14 Kasim"), {"type": "compaction_trigger"}]}
self.assertTrue(o.tetik_mi(req))
self.assertFalse(o.tetik_mi({"input": [msg("user", "normal")]}))
self.assertFalse(o.tetik_mi({"input": []}))
o.ozet_istegi_hazirla(req)
self.assertNotIn("compaction_trigger", json.dumps(req))
self.assertEqual(req["input"][0]["content"][0]["text"], "KARAR-1: 14 Kasim") # gecmis aynen kalir
self.assertEqual(req["input"][-1]["role"], "user")
istem = req["input"][-1]["content"][0]["text"]
for baslik in ("KARARLAR VE GEREKCELERI", "ACIK ISLER", "ONEMLI SONUCLAR", "KULLANICININ ISTEGI"):
self.assertIn(baslik, istem)
self.assertEqual(req["tool_choice"], "none")
def test_kopru_ozeti_her_hedefte_duz_metne_acilir(self):
oge = o.ozet_ogesi("KARAR-1: teslim 14 Kasim\nACIK-IS-1: fatura onayi")
self.assertTrue(oge["id"].startswith("cmp_astrabridge_"))
self.assertNotIn("KARAR-1", json.dumps(oge)) # ogede duz metin gorunmez (base64)
for yabanci in (False, True):
req = {"input": [msg("user", "once"), dict(oge), msg("user", "soru")]}
b = o.ozetleri_ac(req, yabanci_hedef=yabanci)
self.assertEqual(b["acilan"], 1)
metin = req["input"][1]["content"][0]["text"]
self.assertEqual(req["input"][1]["role"], "user")
self.assertIn("KARAR-1: teslim 14 Kasim", metin)
self.assertTrue(metin.startswith(o.OZET_BASLIK))
self.assertNotIn('"compaction"', json.dumps(req))
def test_openai_ozeti_openai_hedefinde_dokunulmaz(self):
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
once = json.dumps(req, sort_keys=True)
b = o.ozetleri_ac(req, yabanci_hedef=False)
self.assertEqual(json.dumps(req, sort_keys=True), once)
self.assertEqual(b, {"acilan": 0, "cozulen": 0, "okunamayan": 0})
def test_openai_ozeti_yabanci_hedefte_duz_metne_cevrilir_ya_da_not_dusulur(self):
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
b = o.ozetleri_ac(req, yabanci_hedef=True, coz=lambda oge: "KARAR-2: saglayici degismeyecek")
self.assertEqual(b["cozulen"], 1)
self.assertIn("KARAR-2", req["input"][0]["content"][0]["text"])
for bozuk in (None, lambda oge: None, lambda oge: 1 / 0):
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
b = o.ozetleri_ac(req, yabanci_hedef=True, coz=bozuk)
self.assertEqual(b["okunamayan"], 1)
self.assertEqual(req["input"][0]["content"][0]["text"], o.OKUNAMADI_NOTU) # sessiz kayip yok
self.assertNotIn("gAAAAAB", json.dumps(req))
def test_coz_istegi_yalniz_gerekli_alanlari_tasir(self):
g = o.coz_istegi(OPENAI_OZETI, "gpt-6-luna")
self.assertEqual(g["input"][0], {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte"})
self.assertEqual(g["model"], "gpt-6-luna")
self.assertFalse(g["store"])
class Akis(unittest.TestCase):
def _mesaj(self, metin, kimlik="msg_1"):
return {"id": kimlik, "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": metin}]}
def test_metin_tek_compaction_ogesine_sarilir(self):
ozet = "1. KULLANICININ ISTEGI: x\n2. KARARLAR: KARAR-1 14 Kasim"
ham = sse({"type": "response.created", "response": {"id": "r1"}},
{"type": "response.output_item.added", "item": {"id": "msg_1", "type": "message"}},
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[:10]},
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[10:]},
{"type": "response.output_item.done", "item": self._mesaj(ozet)},
{"type": "response.completed", "response": {"id": "r1", "output": [self._mesaj(ozet)], "usage": {"input_tokens": 5}}})
a = o.OzetAkisi(tohum="7")
out = b"".join(a.feed(ham[i:i + 53]) for i in range(0, len(ham), 53)) + a.flush()
olaylar = coz(out)
self.assertEqual([e["type"] for e in olaylar], ["response.created", "response.output_item.done", "response.completed"])
oge = olaylar[1]["item"]
self.assertEqual(oge["type"], "compaction")
self.assertEqual(o.ozet_metni(oge), ozet)
self.assertEqual(olaylar[2]["response"]["output"], [oge]) # Codex tam bir compaction ogesi gorur
self.assertEqual(olaylar[2]["response"]["usage"], {"input_tokens": 5})
self.assertEqual(a.metin, ozet)
self.assertNotIn("KARAR-1", out.decode()) # ozet metni akista duz gorunmez
def test_yalniz_delta_gelirse_de_toplanir(self):
ham = sse({"type": "response.created", "response": {}},
{"type": "response.output_text.delta", "item_id": "a", "delta": "ozet "},
{"type": "response.output_text.delta", "item_id": "a", "delta": "metni"},
{"type": "response.completed", "response": {}})
a = o.OzetAkisi()
self.assertEqual(o.ozet_metni(coz(a.feed(ham))[1]["item"]), "ozet metni")
def test_metin_yoksa_compaction_uretilmez_hata_gorunur(self):
ham = sse({"type": "response.created", "response": {}},
{"type": "response.output_item.done", "item": {"type": "function_call", "name": "x"}},
{"type": "response.completed", "response": {"output": []}})
a = o.OzetAkisi()
olaylar = coz(a.feed(ham))
self.assertNotIn("compaction", json.dumps(olaylar))
self.assertFalse(a.metin)
hata = sse({"type": "response.created", "response": {}}, {"type": "response.failed", "response": {"error": {"message": "x"}}})
self.assertIn("response.failed", o.OzetAkisi().feed(hata).decode())
def test_onbellek(self):
klasor = os.path.join(tempfile.mkdtemp(), "c")
self.assertIsNone(o.onbellekten(klasor, OPENAI_OZETI))
o.onbellege(klasor, OPENAI_OZETI, "duz metin ozet")
self.assertEqual(o.onbellekten(klasor, OPENAI_OZETI), "duz metin ozet")
def test_sse_metni(self):
ham = sse({"type": "response.created", "response": {}}, {"type": "response.output_item.done", "item": self._mesaj("cozulmus ozet")},
{"type": "response.completed", "response": {}})
self.assertEqual(o.sse_metni(ham), "cozulmus ozet")
class Fren(unittest.TestCase):
def test_ayni_sohbette_ust_uste_ozet_durdurulur(self):
f = o.DonguFreni(sinir=3, pencere_sn=600)
self.assertEqual([f.izin("sohbet-a", t) for t in (0, 10, 20, 30, 40)], [True, True, True, False, False])
self.assertTrue(f.izin("sohbet-b", 40)) # baska sohbet etkilenmez
self.assertTrue(f.izin("sohbet-a", 700)) # pencere gecince yeniden izin
self.assertIn("Dongu durduruldu", o.DONGU_MESAJI % 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,71 @@
"""py -3 -m unittest test_resim_context (astra-bridge klasöründe)"""
import unittest
from resim_context import resimleri_sadelestir
def resim(bayt):
return {'type': 'input_image', 'image_url': 'data:image/png;base64,' + 'A' * bayt}
class Resim(unittest.TestCase):
def test_generated_image_in_tool_output_is_replaced(self):
req = {'input': [
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'foto'}, resim(150_000)]},
{'type': 'custom_tool_call_output', 'call_id': 'x',
'output': [{'type': 'input_text', 'text': 'tamam'}, resim(3_000_000)]},
]}
sayi, kazanc = resimleri_sadelestir(req)
self.assertEqual(sayi, 1) # son user mesajındaki küçük foto korunur, araç çıktısındaki resim çıkar
self.assertGreater(kazanc, 3_000_000)
self.assertEqual(req['input'][1]['output'][1]['type'], 'input_text')
def test_latest_user_photos_kept_when_small(self):
req = {'input': [
{'type': 'message', 'role': 'user', 'content': [resim(10_000)]},
{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'ok'}]},
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'yeni'}, resim(200_000)]},
]}
sayi, _ = resimleri_sadelestir(req)
self.assertEqual(sayi, 1) # yalnız eski mesajdaki resim
self.assertEqual(req['input'][2]['content'][1]['type'], 'input_image')
self.assertEqual(req['input'][0]['content'][0]['type'], 'input_text')
def test_latest_user_photos_dropped_when_too_big(self):
req = {'input': [{'type': 'message', 'role': 'user', 'content': [resim(1_200_000)]}]}
self.assertEqual(resimleri_sadelestir(req)[0], 1)
def test_pdf_audio_video_in_old_messages_and_tool_output_are_replaced(self):
pdf = {'type': 'input_file', 'filename': 'rapor.pdf', 'file_data': 'data:application/pdf;base64,' + 'P' * 500_000}
ses = {'type': 'input_audio', 'input_audio': {'data': 'S' * 400_000, 'format': 'wav'}}
video = {'type': 'input_video', 'video_url': 'data:video/mp4;base64,' + 'V' * 300_000}
req = {'input': [
{'type': 'message', 'role': 'user', 'content': [pdf, ses]},
{'type': 'function_call_output', 'call_id': 'y', 'output': [video]},
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'devam'}]},
]}
sayi, _ = resimleri_sadelestir(req)
self.assertEqual(sayi, 3)
self.assertIn('rapor.pdf', req['input'][0]['content'][0]['text'])
self.assertIn('ses', req['input'][0]['content'][1]['text'])
self.assertIn('video', req['input'][1]['output'][0]['text'])
def test_huge_text_tool_output_is_shortened(self):
req = {'input': [{'type': 'function_call_output', 'call_id': 'z', 'output': 'x' * 3_000_000}]}
sayi, kazanc = resimleri_sadelestir(req)
self.assertEqual(sayi, 1)
self.assertLess(len(req['input'][0]['output']), 600_000)
self.assertIn('çıkarıldı', req['input'][0]['output'])
def test_unknown_huge_part_is_replaced(self):
req = {'input': [{'type': 'custom_tool_call_output', 'call_id': 'q',
'output': [{'type': 'yeni_bir_tur', 'veri': 'Z' * 1_500_000}]}]}
self.assertEqual(resimleri_sadelestir(req)[0], 1)
def test_request_without_images_untouched(self):
req = {'input': [{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'x'}]}]}
self.assertEqual(resimleri_sadelestir(req), (0, 0))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,35 @@
"""Codex compresses request bodies with zstd; the bridge must be able to read them."""
import ast
import unittest
from pathlib import Path
tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
wanted = {"_zstd_decompress", "_zstd_ready", "decompress"}
env = {}
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted],
type_ignores=[]), "bridge.py", "exec"), env)
class Zstd(unittest.TestCase):
def test_self_check_matches_reality(self):
try:
try:
from compression import zstd as z
packed = z.compress(b"govde" * 50)
except ImportError:
import zstandard
packed = zstandard.ZstdCompressor().compress(b"govde" * 50)
except ImportError:
self.assertFalse(env["_zstd_ready"]()) # no zstd here: the bridge must say so
return
self.assertTrue(env["_zstd_ready"]())
self.assertEqual(env["decompress"](packed, "zstd"), b"govde" * 50)
def test_other_encodings(self):
import gzip
self.assertEqual(env["decompress"](gzip.compress(b"x"), "gzip"), b"x")
self.assertEqual(env["decompress"](b"duz", None), b"duz")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,27 @@
Copyright (c) 2016, Gregory Szorc
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,217 @@
# Copyright (c) 2017-present, Gregory Szorc
# All rights reserved.
#
# This software may be modified and distributed under the terms
# of the BSD license. See the LICENSE file for details.
# ruff: noqa: F403, F405
"""Python interface to the Zstandard (zstd) compression library."""
from __future__ import absolute_import, unicode_literals
# This module serves 2 roles:
#
# 1) Export the C or CFFI "backend" through a central module.
# 2) Implement additional functionality built on top of C or CFFI backend.
import builtins
import io
import os
import platform
import sys
if sys.version_info >= (3, 12):
from collections.abc import Buffer
else:
from typing import ByteString as Buffer
# Some Python implementations don't support C extensions. That's why we have
# a CFFI implementation in the first place. The code here import one of our
# "backends" then re-exports the symbols from this module. For convenience,
# we support falling back to the CFFI backend if the C extension can't be
# imported. But for performance reasons, we only do this on unknown Python
# implementation. Notably, for CPython we require the C extension by default.
# Because someone will inevitably want special behavior, the behavior is
# configurable via an environment variable. A potentially better way to handle
# this is to import a special ``__importpolicy__`` module or something
# defining a variable and `setup.py` could write the file with whatever
# policy was specified at build time. Until someone needs it, we go with
# the hacky but simple environment variable approach.
_module_policy = os.environ.get(
"PYTHON_ZSTANDARD_IMPORT_POLICY", "default"
).strip()
if _module_policy == "default":
if platform.python_implementation() in ("CPython",):
from .backend_c import * # type: ignore
backend = "cext"
elif platform.python_implementation() in ("PyPy",):
from .backend_cffi import * # type: ignore
backend = "cffi"
else:
try:
from .backend_c import *
backend = "cext"
except ImportError:
from .backend_cffi import *
backend = "cffi"
elif _module_policy == "cffi_fallback":
try:
from .backend_c import *
backend = "cext"
except ImportError:
from .backend_cffi import *
backend = "cffi"
elif _module_policy == "rust":
from .backend_rust import * # type: ignore
backend = "rust"
elif _module_policy == "cext":
from .backend_c import *
backend = "cext"
elif _module_policy == "cffi":
from .backend_cffi import *
backend = "cffi"
else:
raise ImportError(
"unknown module import policy: %s; use default, cffi_fallback, "
"cext, or cffi" % _module_policy
)
# Keep this in sync with python-zstandard.h, rust-ext/src/lib.rs, and debian/changelog.
__version__ = "0.25.0"
_MODE_CLOSED = 0
_MODE_READ = 1
_MODE_WRITE = 2
def open(
filename,
mode="rb",
cctx=None,
dctx=None,
encoding=None,
errors=None,
newline=None,
closefd=None,
):
"""Create a file object with zstd (de)compression.
The object returned from this function will be a
:py:class:`ZstdDecompressionReader` if opened for reading in binary mode,
a :py:class:`ZstdCompressionWriter` if opened for writing in binary mode,
or an ``io.TextIOWrapper`` if opened for reading or writing in text mode.
:param filename:
``bytes``, ``str``, or ``os.PathLike`` defining a file to open or a
file object (with a ``read()`` or ``write()`` method).
:param mode:
``str`` File open mode. Accepts any of the open modes recognized by
``open()``.
:param cctx:
``ZstdCompressor`` to use for compression. If not specified and file
is opened for writing, the default ``ZstdCompressor`` will be used.
:param dctx:
``ZstdDecompressor`` to use for decompression. If not specified and file
is opened for reading, the default ``ZstdDecompressor`` will be used.
:param encoding:
``str`` that defines text encoding to use when file is opened in text
mode.
:param errors:
``str`` defining text encoding error handling mode.
:param newline:
``str`` defining newline to use in text mode.
:param closefd:
``bool`` whether to close the file when the returned object is closed.
Only used if a file object is passed. If a filename is specified, the
opened file is always closed when the returned object is closed.
"""
normalized_mode = mode.replace("t", "")
if normalized_mode in ("r", "rb"):
dctx = dctx or ZstdDecompressor()
open_mode = "r"
raw_open_mode = "rb"
elif normalized_mode in ("w", "wb", "a", "ab", "x", "xb"):
cctx = cctx or ZstdCompressor()
open_mode = "w"
raw_open_mode = normalized_mode
if not raw_open_mode.endswith("b"):
raw_open_mode = raw_open_mode + "b"
else:
raise ValueError("Invalid mode: {!r}".format(mode))
if hasattr(os, "PathLike"):
types = (str, bytes, os.PathLike)
else:
types = (str, bytes)
if isinstance(filename, types): # type: ignore
inner_fh = builtins.open(filename, raw_open_mode)
closefd = True
elif hasattr(filename, "read") or hasattr(filename, "write"):
inner_fh = filename
closefd = bool(closefd)
else:
raise TypeError(
"filename must be a str, bytes, file or PathLike object"
)
if open_mode == "r":
fh = dctx.stream_reader(inner_fh, closefd=closefd)
elif open_mode == "w":
fh = cctx.stream_writer(inner_fh, closefd=closefd)
else:
raise RuntimeError("logic error in zstandard.open() handling open mode")
if "b" not in normalized_mode:
return io.TextIOWrapper(
fh, encoding=encoding, errors=errors, newline=newline
)
else:
return fh
def compress(data: Buffer, level: int = 3) -> bytes:
"""Compress source data using the zstd compression format.
This performs one-shot compression using basic/default compression
settings.
This method is provided for convenience and is equivalent to calling
``ZstdCompressor(level=level).compress(data)``.
If you find yourself calling this function in a tight loop,
performance will be greater if you construct a single ``ZstdCompressor``
and repeatedly call ``compress()`` on it.
"""
cctx = ZstdCompressor(level=level)
return cctx.compress(data)
def decompress(data: Buffer, max_output_size: int = 0) -> bytes:
"""Decompress a zstd frame into its original data.
This performs one-shot decompression using basic/default compression
settings.
This method is provided for convenience and is equivalent to calling
``ZstdDecompressor().decompress(data, max_output_size=max_output_size)``.
If you find yourself calling this function in a tight loop, performance
will be greater if you construct a single ``ZstdDecompressor`` and
repeatedly call ``decompress()`` on it.
"""
dctx = ZstdDecompressor()
return dctx.decompress(data, max_output_size=max_output_size)
@@ -0,0 +1,481 @@
# Copyright (c) 2016-present, Gregory Szorc
# All rights reserved.
#
# This software may be modified and distributed under the terms
# of the BSD license. See the LICENSE file for details.
import os
from typing import (
IO,
BinaryIO,
ByteString,
Generator,
Iterable,
List,
Optional,
Set,
Tuple,
Union,
)
FLUSH_BLOCK: int
FLUSH_FRAME: int
COMPRESSOBJ_FLUSH_FINISH: int
COMPRESSOBJ_FLUSH_BLOCK: int
CONTENTSIZE_UNKNOWN: int
CONTENTSIZE_ERROR: int
MAX_COMPRESSION_LEVEL: int
COMPRESSION_RECOMMENDED_INPUT_SIZE: int
COMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
DECOMPRESSION_RECOMMENDED_INPUT_SIZE: int
DECOMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
BLOCKSIZELOG_MAX: int
BLOCKSIZE_MAX: int
WINDOWLOG_MIN: int
WINDOWLOG_MAX: int
CHAINLOG_MIN: int
CHAINLOG_MAX: int
HASHLOG_MIN: int
HASHLOG_MAX: int
MINMATCH_MIN: int
MINMATCH_MAX: int
SEARCHLOG_MIN: int
SEARCHLOG_MAX: int
SEARCHLENGTH_MIN: int
SEARCHLENGTH_MAX: int
TARGETLENGTH_MIN: int
TARGETLENGTH_MAX: int
LDM_MINMATCH_MIN: int
LDM_MINMATCH_MAX: int
LDM_BUCKETSIZELOG_MAX: int
STRATEGY_FAST: int
STRATEGY_DFAST: int
STRATEGY_GREEDY: int
STRATEGY_LAZY: int
STRATEGY_LAZY2: int
STRATEGY_BTLAZY2: int
STRATEGY_BTOPT: int
STRATEGY_BTULTRA: int
STRATEGY_BTULTRA2: int
DICT_TYPE_AUTO: int
DICT_TYPE_RAWCONTENT: int
DICT_TYPE_FULLDICT: int
FORMAT_ZSTD1: int
FORMAT_ZSTD1_MAGICLESS: int
ZSTD_VERSION: Tuple[int, int, int]
FRAME_HEADER: bytes
MAGIC_NUMBER: int
backend: str
backend_features: Set[str]
__version__: str
class ZstdError(Exception): ...
class BufferSegment(object):
offset: int
def __len__(self) -> int: ...
def tobytes(self) -> bytes: ...
class BufferSegments(object):
def __len__(self) -> int: ...
def __getitem__(self, i: int) -> BufferSegment: ...
class BufferWithSegments(object):
size: int
def __init__(self, data: ByteString, segments: ByteString): ...
def __len__(self) -> int: ...
def __getitem__(self, i: int) -> BufferSegment: ...
def segments(self): ...
def tobytes(self) -> bytes: ...
class BufferWithSegmentsCollection(object):
def __init__(self, *args): ...
def __len__(self) -> int: ...
def __getitem__(self, i: int) -> BufferSegment: ...
def size(self) -> int: ...
class ZstdCompressionParameters(object):
@staticmethod
def from_level(
level: int, source_size: int = ..., dict_size: int = ..., **kwargs
) -> "ZstdCompressionParameters": ...
def __init__(
self,
format: int = ...,
compression_level: int = ...,
window_log: int = ...,
hash_log: int = ...,
chain_log: int = ...,
search_log: int = ...,
min_match: int = ...,
target_length: int = ...,
strategy: int = ...,
write_content_size: int = ...,
write_checksum: int = ...,
write_dict_id: int = ...,
job_size: int = ...,
overlap_log: int = ...,
force_max_window: int = ...,
enable_ldm: int = ...,
ldm_hash_log: int = ...,
ldm_min_match: int = ...,
ldm_bucket_size_log: int = ...,
ldm_hash_rate_log: int = ...,
threads: int = ...,
): ...
@property
def format(self) -> int: ...
@property
def compression_level(self) -> int: ...
@property
def window_log(self) -> int: ...
@property
def hash_log(self) -> int: ...
@property
def chain_log(self) -> int: ...
@property
def search_log(self) -> int: ...
@property
def min_match(self) -> int: ...
@property
def target_length(self) -> int: ...
@property
def strategy(self) -> int: ...
@property
def write_content_size(self) -> int: ...
@property
def write_checksum(self) -> int: ...
@property
def write_dict_id(self) -> int: ...
@property
def job_size(self) -> int: ...
@property
def overlap_log(self) -> int: ...
@property
def force_max_window(self) -> int: ...
@property
def enable_ldm(self) -> int: ...
@property
def ldm_hash_log(self) -> int: ...
@property
def ldm_min_match(self) -> int: ...
@property
def ldm_bucket_size_log(self) -> int: ...
@property
def ldm_hash_rate_log(self) -> int: ...
@property
def threads(self) -> int: ...
def estimated_compression_context_size(self) -> int: ...
class CompressionParameters(ZstdCompressionParameters): ...
class ZstdCompressionDict(object):
k: int
d: int
def __init__(
self,
data: ByteString,
dict_type: int = ...,
k: int = ...,
d: int = ...,
): ...
def __len__(self) -> int: ...
def dict_id(self) -> int: ...
def as_bytes(self) -> bytes: ...
def precompute_compress(
self,
level: int = ...,
compression_params: ZstdCompressionParameters = ...,
): ...
class ZstdCompressionObj(object):
def compress(self, data: ByteString) -> bytes: ...
def flush(self, flush_mode: int = ...) -> bytes: ...
class ZstdCompressionChunker(object):
def compress(self, data: ByteString): ...
def flush(self): ...
def finish(self): ...
class ZstdCompressionReader(BinaryIO):
def __enter__(self) -> "ZstdCompressionReader": ...
def __exit__(self, exc_type, exc_value, exc_tb): ...
def readable(self) -> bool: ...
def writable(self) -> bool: ...
def seekable(self) -> bool: ...
def readline(self, limit: int = ...) -> bytes: ...
def readlines(self, hint: int = ...) -> List[bytes]: ...
def write(self, data: ByteString): ...
def writelines(self, data: Iterable[bytes]): ...
def isatty(self) -> bool: ...
def flush(self): ...
def close(self): ...
@property
def closed(self) -> bool: ...
def tell(self) -> int: ...
def readall(self) -> bytes: ...
def __iter__(self): ...
def __next__(self): ...
def next(self): ...
def read(self, size: int = ...) -> bytes: ...
def read1(self, size: int = ...) -> bytes: ...
def readinto(self, b) -> int: ...
def readinto1(self, b) -> int: ...
class ZstdCompressionWriter(BinaryIO):
def __enter__(self) -> "ZstdCompressionWriter": ...
def __exit__(self, exc_type, exc_value, exc_tb): ...
def memory_size(self) -> int: ...
def fileno(self) -> int: ...
def close(self): ...
@property
def closed(self) -> bool: ...
def isatty(self) -> bool: ...
def readable(self) -> bool: ...
def readline(self, size: int = ...) -> bytes: ...
def readlines(self, hint: int = ...) -> List[bytes]: ...
def seek(self, offset: int, whence: int = ...): ...
def seekable(self) -> bool: ...
def truncate(self, size: int = ...): ...
def writable(self) -> bool: ...
def writelines(self, lines: Iterable[bytes]): ...
def read(self, size: int = ...) -> bytes: ...
def readall(self) -> bytes: ...
def readinto(self, b): ...
def write(self, data: ByteString) -> int: ...
def flush(self, flush_mode: int = ...) -> int: ...
def tell(self) -> int: ...
class ZstdCompressor(object):
def __init__(
self,
level: int = ...,
dict_data: Optional[ZstdCompressionDict] = ...,
compression_params: Optional[ZstdCompressionParameters] = ...,
write_checksum: Optional[bool] = ...,
write_content_size: Optional[bool] = ...,
write_dict_id: Optional[bool] = ...,
threads: int = ...,
): ...
def memory_size(self) -> int: ...
def compress(self, data: ByteString) -> bytes: ...
def compressobj(self, size: int = ...) -> ZstdCompressionObj: ...
def chunker(
self, size: int = ..., chunk_size: int = ...
) -> ZstdCompressionChunker: ...
def copy_stream(
self,
ifh: IO[bytes],
ofh: IO[bytes],
size: int = ...,
read_size: int = ...,
write_size: int = ...,
) -> Tuple[int, int]: ...
def stream_reader(
self,
source: Union[IO[bytes], ByteString],
size: int = ...,
read_size: int = ...,
*,
closefd: bool = ...,
) -> ZstdCompressionReader: ...
def stream_writer(
self,
writer: IO[bytes],
size: int = ...,
write_size: int = ...,
write_return_read: bool = ...,
*,
closefd: bool = ...,
) -> ZstdCompressionWriter: ...
def read_to_iter(
self,
reader: Union[IO[bytes], ByteString],
size: int = ...,
read_size: int = ...,
write_size: int = ...,
) -> Generator[bytes, None, None]: ...
def frame_progression(self) -> Tuple[int, int, int]: ...
def multi_compress_to_buffer(
self,
data: Union[
BufferWithSegments,
BufferWithSegmentsCollection,
List[ByteString],
],
threads: int = ...,
) -> BufferWithSegmentsCollection: ...
class ZstdDecompressionObj(object):
def decompress(self, data: ByteString) -> bytes: ...
def flush(self, length: int = ...) -> bytes: ...
@property
def unused_data(self) -> bytes: ...
@property
def unconsumed_tail(self) -> bytes: ...
@property
def eof(self) -> bool: ...
class ZstdDecompressionReader(BinaryIO):
def __enter__(self) -> "ZstdDecompressionReader": ...
def __exit__(self, exc_type, exc_value, exc_tb): ...
def readable(self) -> bool: ...
def writable(self) -> bool: ...
def seekable(self) -> bool: ...
def readline(self, size: int = ...): ...
def readlines(self, hint: int = ...): ...
def write(self, data: ByteString): ...
def writelines(self, lines: Iterable[bytes]): ...
def isatty(self) -> bool: ...
def flush(self): ...
def close(self): ...
@property
def closed(self) -> bool: ...
def tell(self) -> int: ...
def readall(self) -> bytes: ...
def __iter__(self): ...
def __next__(self): ...
def next(self): ...
def read(self, size: int = ...) -> bytes: ...
def readinto(self, b) -> int: ...
def read1(self, size: int = ...) -> bytes: ...
def readinto1(self, b) -> int: ...
def seek(self, pos: int, whence: int = ...) -> int: ...
class ZstdDecompressionWriter(BinaryIO):
def __enter__(self) -> "ZstdDecompressionWriter": ...
def __exit__(self, exc_type, exc_value, exc_tb): ...
def memory_size(self) -> int: ...
def close(self): ...
@property
def closed(self) -> bool: ...
def fileno(self) -> int: ...
def flush(self): ...
def isatty(self) -> bool: ...
def readable(self) -> bool: ...
def readline(self, size: int = ...): ...
def readlines(self, hint: int = ...): ...
def seek(self, offset: int, whence: int = ...): ...
def seekable(self) -> bool: ...
def tell(self): ...
def truncate(self, size: int = ...): ...
def writable(self) -> bool: ...
def writelines(self, lines: Iterable[bytes]): ...
def read(self, size: int = ...): ...
def readall(self): ...
def readinto(self, b): ...
def write(self, data: ByteString) -> int: ...
class ZstdDecompressor(object):
def __init__(
self,
dict_data: Optional[ZstdCompressionDict] = ...,
max_window_size: int = ...,
format: int = ...,
): ...
def memory_size(self) -> int: ...
def decompress(
self,
data: ByteString,
max_output_size: int = ...,
read_across_frames: bool = ...,
allow_extra_data: bool = ...,
) -> bytes: ...
def stream_reader(
self,
source: Union[IO[bytes], ByteString],
read_size: int = ...,
read_across_frames: bool = ...,
*,
closefd=False,
) -> ZstdDecompressionReader: ...
def decompressobj(
self, write_size: int = ..., read_across_frames: bool = False
) -> ZstdDecompressionObj: ...
def read_to_iter(
self,
reader: Union[IO[bytes], ByteString],
read_size: int = ...,
write_size: int = ...,
skip_bytes: int = ...,
) -> Generator[bytes, None, None]: ...
def stream_writer(
self,
writer: IO[bytes],
write_size: int = ...,
write_return_read: bool = ...,
*,
closefd: bool = ...,
) -> ZstdDecompressionWriter: ...
def copy_stream(
self,
ifh: IO[bytes],
ofh: IO[bytes],
read_size: int = ...,
write_size: int = ...,
) -> Tuple[int, int]: ...
def decompress_content_dict_chain(
self, frames: list[ByteString]
) -> bytes: ...
def multi_decompress_to_buffer(
self,
frames: Union[
BufferWithSegments,
BufferWithSegmentsCollection,
List[ByteString],
],
decompressed_sizes: ByteString = ...,
threads: int = ...,
) -> BufferWithSegmentsCollection: ...
class FrameParameters(object):
content_size: int
window_size: int
dict_id: int
has_checksum: bool
def estimate_decompression_context_size() -> int: ...
def frame_content_size(data: ByteString) -> int: ...
def frame_header_size(data: ByteString) -> int: ...
def get_frame_parameters(
data: ByteString, format: Optional[int] = None
) -> FrameParameters: ...
def train_dictionary(
dict_size: int,
samples: list[ByteString],
k: int = ...,
d: int = ...,
f: int = ...,
split_point: float = ...,
accel: int = ...,
notifications: int = ...,
dict_id: int = ...,
level: int = ...,
steps: int = ...,
threads: int = ...,
) -> ZstdCompressionDict: ...
def open(
filename: Union[bytes, str, os.PathLike, BinaryIO],
mode: str = ...,
cctx: Optional[ZstdCompressor] = ...,
dctx: Optional[ZstdDecompressor] = ...,
encoding: Optional[str] = ...,
errors: Optional[str] = ...,
newline: Optional[str] = ...,
closefd: bool = ...,
): ...
def compress(data: ByteString, level: int = ...) -> bytes: ...
def decompress(data: ByteString, max_output_size: int = ...) -> bytes: ...
File diff suppressed because it is too large. Load diff
Whitespace-only changes.
@@ -0,0 +1,51 @@
{
"schemaVersion": 1,
"product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)",
"version": "14.44.35211.0",
"architecture": "x64",
"publisher": "Microsoft Corporation (Authenticode doğrulandı)",
"files": [
{
"name": "msvcp140.dll",
"size": 557728,
"sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9"
},
{
"name": "msvcp140_1.dll",
"size": 35952,
"sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d"
},
{
"name": "msvcp140_2.dll",
"size": 280200,
"sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9"
},
{
"name": "msvcp140_atomic_wait.dll",
"size": 50304,
"sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479"
},
{
"name": "msvcp140_codecvt_ids.dll",
"size": 31872,
"sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18"
},
{
"name": "concrt140.dll",
"size": 324208,
"sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376"
},
{
"name": "vcruntime140.dll",
"size": 124544,
"sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066",
"ownedByPython": true
},
{
"name": "vcruntime140_1.dll",
"size": 49792,
"sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f",
"ownedByPython": true
}
]
}
@@ -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.
}
})
}
}
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest'
import {
addCodexLinkLine,
CODEX_LINK_LINE,
CODEX_LINK_RUN_COMMAND_MAX,
codexAutostartPlan,
codexBridgeArgs,
codexBridgeCommandLine,
codexBridgeConfig,
codexBridgeLauncherSource,
inspectCodexConfig,
parseCodexLinkSettings,
removeCodexLinkLine,
serializeCodexLinkSettings
} from './codex-link-config'
const FOREIGN = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"'
// Add then remove must hand the user's file back exactly as it was.
function expectRoundTrip(original: string) {
const added = addCodexLinkLine(original)
expect(added.changed).toBe(true)
expect(added.owner).toBe('ours')
expect(inspectCodexConfig(added.text)).toBe('ours')
const removed = removeCodexLinkLine(added.text)
expect(removed.changed).toBe(true)
expect(removed.text).toBe(original)
return added.text
}
describe('the contract line', () => {
it('is the exact text Codex and the bridge agree on', () => {
expect(CODEX_LINK_LINE).toBe(
'openai_base_url = "http://127.0.0.1:8795/backend-api/codex" # AITURK IDE Codex Baglama'
)
})
})
describe('addCodexLinkLine', () => {
it('writes the line into an empty file', () => {
expect(expectRoundTrip('')).toBe(`${CODEX_LINK_LINE}\n`)
})
it('puts the line before the first table when the file only has tables', () => {
const original = '[projects."C:\\\\work"]\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "node"\n'
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[projects.'))
expect(text.endsWith(original)).toBe(true)
})
it('keeps existing top-level keys and tables intact', () => {
const original = '# my config\nmodel = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n'
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[features]'))
expect(text).toContain('model = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n')
})
it('preserves a CRLF file and writes its own line with CRLF', () => {
const original = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
const text = expectRoundTrip(original)
expect(text).toBe(`${CODEX_LINK_LINE}\r\n${original}`)
expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/)
})
it('keeps a byte-order mark at the very start', () => {
const text = expectRoundTrip('\uFEFFmodel = "gpt-6"\n')
expect(text.startsWith(`\uFEFF${CODEX_LINK_LINE}\n`)).toBe(true)
})
it('keeps a file without a trailing newline exactly as it was', () => {
expectRoundTrip('model = "gpt-6"\n[features]\nweb = true')
})
it('is idempotent once our line is there', () => {
const once = addCodexLinkLine('model = "gpt-6"\n[features]\n').text
const twice = addCodexLinkLine(once)
expect(twice.changed).toBe(false)
expect(twice.owner).toBe('ours')
expect(twice.text).toBe(once)
})
it('refreshes a stale copy of our own line in place', () => {
const stale = 'model = "gpt-6"\nopenai_base_url = "http://127.0.0.1:1/x" # AITURK IDE Codex Baglama\n[features]\n'
const edit = addCodexLinkLine(stale)
expect(edit.changed).toBe(true)
expect(edit.text).toBe(`model = "gpt-6"\n${CODEX_LINK_LINE}\n[features]\n`)
})
it('still owns its line after Codex rewrote the config and dropped the comment', () => {
const rewritten = 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex"\nmodel = "x"\n'
expect(inspectCodexConfig(rewritten)).toBe('ours')
expect(removeCodexLinkLine(rewritten).text).toBe('model = "x"\n')
// Another local port is somebody else's bridge.
expect(inspectCodexConfig('openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n')).toBe('foreign')
})
it('does not touch a foreign openai_base_url and reports it', () => {
for (const original of [
`${FOREIGN}\n[features]\n`,
`model = "gpt-6"\r\n ${FOREIGN} # my own bridge\r\n`,
'"openai_base_url" = "https://example.test/v1"\n',
"'openai_base_url'='https://example.test/v1'\n"
]) {
const edit = addCodexLinkLine(original)
expect(edit).toEqual({ changed: false, owner: 'foreign', text: original })
expect(inspectCodexConfig(original)).toBe('foreign')
}
})
it('does not treat the same key under a table as top-level', () => {
const original = 'model = "gpt-6"\n\n[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n'
expect(inspectCodexConfig(original)).toBe('absent')
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[model_providers.mine]'))
expect(text).toContain('[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n')
})
it('ignores the key inside a comment', () => {
const original = '# openai_base_url = "https://example.test/v1"\n #openai_base_url = "x"\nmodel = "gpt-6"\n'
expect(inspectCodexConfig(original)).toBe('absent')
expectRoundTrip(original)
})
it('ignores look-alikes inside multi-line values', () => {
// Neither the "key" inside the string nor the "[table]" inside it is real,
// so the genuine foreign key after it must still be found.
const inString = 'notes = """\nopenai_base_url = "nope"\n[not.a.table]\n"""\nmodel = "gpt-6"\n'
expect(inspectCodexConfig(inString)).toBe('absent')
expect(inspectCodexConfig(`${inString}${FOREIGN}\n`)).toBe('foreign')
const inArray = 'notify = [\n "a",\n ["b"]\n]\n'
expect(inspectCodexConfig(`${inArray}${FOREIGN}\n`)).toBe('foreign')
expect(inspectCodexConfig(`literal = '''\n[x]\n'''\n${FOREIGN}\n`)).toBe('foreign')
})
it('is not fooled by a bracket or hash inside a quoted value', () => {
const original = `title = "a [b] # c"\n${FOREIGN}\n`
expect(inspectCodexConfig(original)).toBe('foreign')
})
})
describe('removeCodexLinkLine', () => {
it('removes only our line, wherever the user moved it', () => {
const text = `model = "gpt-6"\r\n${CODEX_LINK_LINE}\r\napproval_policy = "never"\r\n[features]\r\n`
const edit = removeCodexLinkLine(text)
expect(edit).toEqual({
changed: true,
owner: 'absent',
text: 'model = "gpt-6"\r\napproval_policy = "never"\r\n[features]\r\n'
})
})
it('leaves a file without our line untouched', () => {
for (const original of ['', 'model = "gpt-6"\n', `${FOREIGN}\n[features]\n`]) {
expect(removeCodexLinkLine(original).changed).toBe(false)
expect(removeCodexLinkLine(original).text).toBe(original)
}
})
it('never removes a foreign line', () => {
expect(removeCodexLinkLine(`${FOREIGN}\n`)).toEqual({ changed: false, owner: 'foreign', text: `${FOREIGN}\n` })
})
it('leaves our marker alone when it sits under a table', () => {
const original = `[model_providers.mine]\n${CODEX_LINK_LINE}\n`
expect(removeCodexLinkLine(original)).toEqual({ changed: false, owner: 'absent', text: original })
})
})
describe('preference file', () => {
it('is on by default when the file is missing or malformed', () => {
for (const raw of [null, undefined, '', '{', 'null', '[]', '"off"', '{"kapsam":"ide"}', '{"enabled":"false"}']) {
expect(parseCodexLinkSettings(raw).enabled).toBe(true)
}
})
it('is off only when explicitly disabled', () => {
expect(parseCodexLinkSettings('{"enabled": false, "kapsam": "ide"}').enabled).toBe(false)
expect(parseCodexLinkSettings('{"enabled": true}').enabled).toBe(true)
})
it('writes the contract shape and keeps unknown fields', () => {
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' })
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
enabled: true,
gelecek: { a: 1 },
kapsam: 'hepsi'
})
})
})
describe('bridge configuration and command', () => {
const launch = {
bridgeHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\codex-bridge',
bridgeScript: 'C:\\Program Files\\AITURK IDE\\resources\\codex-bridge\\bridge.py',
hermesHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent'
}
const python = `${launch.hermesHome}\\hermes-agent\\venv\\Scripts\\pythonw.exe`
it('writes the agreed bridge.json', () => {
expect(codexBridgeConfig()).toEqual({
port: 8795,
chatgpt_backend: 'https://chatgpt.com',
routes: {},
nine_router_catalog: { enabled: false },
pano_kanca_user: false,
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
})
})
it('passes the same arguments to the spawn and the login item', () => {
expect(codexBridgeArgs(launch)).toEqual([
launch.bridgeScript,
'--home',
launch.bridgeHome,
'--hermes-home',
launch.hermesHome
])
expect(codexBridgeCommandLine(python, launch)).toBe(
`"${python}" "${launch.bridgeScript}" --home "${launch.bridgeHome}" --hermes-home "${launch.hermesHome}"`
)
})
it('drops a trailing separator that would escape the closing quote', () => {
expect(
codexBridgeCommandLine('C:\\py\\pythonw.exe', {
bridgeHome: 'C:\\d\\',
bridgeScript: 'C:\\b.py',
hermesHome: 'C:\\h\\'
})
).toBe('"C:\\py\\pythonw.exe" "C:\\b.py" --home "C:\\d" --hermes-home "C:\\h"')
})
it('uses the direct command for the login item when it fits a Run value', () => {
const short = { bridgeHome: 'C:\\d', bridgeScript: 'C:\\b\\bridge.py', hermesHome: 'C:\\h' }
expect(codexAutostartPlan('C:\\py\\pythonw.exe', short, 'C:\\d\\baslat.pyw')).toEqual({
command: '"C:\\py\\pythonw.exe" "C:\\b\\bridge.py" --home "C:\\d" --hermes-home "C:\\h"',
launcher: null
})
})
it('falls back to a launcher when the direct command is too long for a Run value', () => {
expect(codexBridgeCommandLine(python, launch).length).toBeGreaterThan(CODEX_LINK_RUN_COMMAND_MAX)
const launcherPath = `${launch.bridgeHome}\\baslat.pyw`
const plan = codexAutostartPlan(python, launch, launcherPath)
expect(plan?.command).toBe(`"${python}" "${launcherPath}"`)
expect(plan?.command.length).toBeLessThanOrEqual(CODEX_LINK_RUN_COMMAND_MAX)
expect(plan?.launcher).toEqual({ path: launcherPath, source: codexBridgeLauncherSource(launch) })
// The launcher carries the very same argv as the direct command.
expect(plan?.launcher?.source).toContain(`ARGV = ${JSON.stringify(codexBridgeArgs(launch))}`)
})
it('registers nothing when not even the launcher command fits', () => {
const deep = `C:\\${'x'.repeat(300)}`
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
})
})
+430
View File
@@ -0,0 +1,430 @@
/**
* Codex Link ("Codex Bağlama") — the pure half.
*
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
* installed on this machine use the IDE's models and combos. Turning the
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
* and registering a login item, so every text transformation lives here as a
* dependency-free, byte-preserving function that can be proven without
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
* the side effects.
*
* Invariants:
* - Only the line carrying OUR trailing comment is ever written or removed.
* - An `openai_base_url` that is not ours means someone else's bridge is in
* charge: report `foreign` and change nothing.
* - Everything else in the file — including its line endings and BOM — comes
* back byte for byte.
*/
export const CODEX_LINK_PORT = 8795
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/refresh`
/** The trailing comment that marks the line as ours. */
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
// another version is restarted so an updated IDE never keeps an old bridge alive.
export const CODEX_BRIDGE_VERSION = '2'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
export const CODEX_LINK_BACKUP_SUFFIX = '.aiturk-yedek'
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
// Windows documents Run-key command lines as "no longer than 260 characters".
// A longer value is not reliably started at sign-in, so the direct command is
// only used when it fits (see autostartPlan).
export const CODEX_LINK_RUN_COMMAND_MAX = 260
/** Who owns the top-level `openai_base_url` in a Codex config. */
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
interface Line {
eol: string
text: string
}
const BOM = '\uFEFF'
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
function splitLines(source: string): Line[] {
const lines: Line[] = []
let start = 0
while (start < source.length) {
let end = start
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
end += 1
}
let eol = ''
if (end < source.length) {
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
}
lines.push({ eol, text: source.slice(start, end) })
start = end + eol.length
}
return lines
}
function joinLines(lines: Line[]): string {
return lines.map(line => line.text + line.eol).join('')
}
function splitBom(source: string): { body: string; bom: string } {
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
}
type MultilineQuote = "'''" | '"""' | null
interface ValueState {
depth: number
multiline: MultilineQuote
}
// Index just past the `=` of a `key = value` line, or the line length when the
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
function valueStart(text: string): number {
let pos = 0
while (pos < text.length) {
const char = text[pos]
if (char === '#') {
return text.length
}
if (char === '=') {
return pos + 1
}
if (char === '"' || char === "'") {
pos += 1
while (pos < text.length && text[pos] !== char) {
pos += char === '"' && text[pos] === '\\' ? 2 : 1
}
}
pos += 1
}
return text.length
}
// Walk a value just far enough to know whether the NEXT line is still inside it
// (multi-line string or array). That is the only TOML the top-level scan needs:
// without it, text inside a multi-line value could pass for a key or a table
// header.
function scanValue(text: string, from: number, state: ValueState): ValueState {
let { depth, multiline } = state
let pos = from
while (pos < text.length) {
if (multiline) {
if (multiline === '"""' && text[pos] === '\\') {
pos += 2
continue
}
if (text.startsWith(multiline, pos)) {
pos += 3
// Up to two quotes may sit directly before the closing delimiter.
while (text[pos] === multiline[0]) {
pos += 1
}
multiline = null
continue
}
pos += 1
continue
}
const char = text[pos]
if (char === '#') {
break
}
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
multiline = text.slice(pos, pos + 3) as MultilineQuote
pos += 3
continue
}
if (char === '"' || char === "'") {
pos += 1
while (pos < text.length && text[pos] !== char) {
pos += char === '"' && text[pos] === '\\' ? 2 : 1
}
pos += 1
continue
}
if (char === '[' || char === '{') {
depth += 1
} else if (char === ']' || char === '}') {
depth = Math.max(0, depth - 1)
}
pos += 1
}
return { depth, multiline }
}
// Indices of the top-level `openai_base_url` lines: those before the first
// `[table]` header, outside comments and multi-line values.
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
const entries: { index: number; ours: boolean }[] = []
let state: ValueState = { depth: 0, multiline: null }
for (let index = 0; index < lines.length; index += 1) {
const { text } = lines[index]
let from = 0
if (!state.multiline && state.depth === 0) {
if (text.trimStart().startsWith('[')) {
break
}
if (KEY_PATTERN.test(text)) {
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
entries.push({
index,
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
})
}
from = valueStart(text)
}
state = scanValue(text, from, state)
}
return entries
}
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
if (entries.some(entry => !entry.ours)) {
return 'foreign'
}
return entries.length > 0 ? 'ours' : 'absent'
}
/** Who owns the top-level `openai_base_url` of this config text. */
export function inspectCodexConfig(source: string): CodexConfigOwner {
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
}
export interface CodexConfigEdit {
changed: boolean
owner: CodexConfigOwner
text: string
}
/**
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
* text untouched and reports `foreign`.
*
* The line goes on the very first line: top-level keys must precede the first
* table header, and the top of the file is the one position that is always
* top-level without having to understand the values below it.
*/
export function addCodexLinkLine(source: string): CodexConfigEdit {
const { body, bom } = splitBom(source)
const lines = splitLines(body)
const entries = topLevelEntries(lines)
const owner = ownerOf(entries)
if (owner === 'foreign') {
return { changed: false, owner, text: source }
}
if (owner === 'ours') {
const [first, ...duplicates] = entries
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
return { changed: false, owner, text: source }
}
// A stale copy of our line (older address) or an accidental duplicate:
// normalize to exactly one current line, in place.
const drop = new Set(duplicates.map(entry => entry.index))
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
}
const eol = body.includes('\r\n') ? '\r\n' : '\n'
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
}
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
export function removeCodexLinkLine(source: string): CodexConfigEdit {
const { body, bom } = splitBom(source)
const lines = splitLines(body)
const entries = topLevelEntries(lines)
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
const remaining = ownerOf(entries.filter(entry => !entry.ours))
if (drop.size === 0) {
return { changed: false, owner: remaining, text: source }
}
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
}
// ── Preference file ─────────────────────────────────────────────────────────
export interface CodexLinkSettings {
enabled: boolean
/** Every field of the stored object, so a rewrite keeps what it did not set. */
stored: Record<string, unknown>
}
/** Missing, unreadable or malformed means ON: the feature is on by default. */
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
if (typeof raw !== 'string') {
return { enabled: true, stored: {} }
}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { enabled: true, stored: {} }
}
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
} catch {
return { enabled: true, stored: {} }
}
}
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
}
// ── Bridge configuration ────────────────────────────────────────────────────
/** `bridge.json`, rewritten on every apply. */
export function codexBridgeConfig() {
return {
port: CODEX_LINK_PORT,
chatgpt_backend: 'https://chatgpt.com',
routes: {},
nine_router_catalog: { enabled: false },
pano_kanca_user: false,
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
}
}
export function serializeCodexBridgeConfig(): string {
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
}
// ── Bridge command line ─────────────────────────────────────────────────────
export interface CodexBridgeLaunch {
bridgeHome: string
bridgeScript: string
hermesHome: string
}
/** Arguments after the interpreter — identical for the spawn and the login item. */
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
}
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
function quoted(value: string): string {
return `"${value.replace(/[\\/]+$/, '')}"`
}
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
}
/**
* A tiny windowless launcher for the login item, used when the direct command
* would not fit a Run value. It runs the bridge exactly as the direct command
* would: same script, same arguments, the script's folder first on sys.path so
* its helper modules import.
*/
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
const argv = JSON.stringify(codexBridgeArgs(launch))
return [
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
'import os',
'import runpy',
'import sys',
'',
`ARGV = ${argv}`,
'',
'sys.argv = list(ARGV)',
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
"runpy.run_path(ARGV[0], run_name='__main__')",
''
].join('\n')
}
export interface CodexAutostartPlan {
command: string
/** Launcher file to write first; null when the direct command is used. */
launcher: { path: string; source: string } | null
}
/**
* The login-item command. Prefer the direct bridge command; fall back to the
* launcher when that exceeds the Run-value limit. Returns null when not even
* the launcher form fits (nothing is registered rather than something that
* silently never starts).
*/
export function codexAutostartPlan(
python: string,
launch: CodexBridgeLaunch,
launcherPath: string
): CodexAutostartPlan | null {
const direct = codexBridgeCommandLine(python, launch)
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
return { command: direct, launcher: null }
}
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
return null
}
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Codex Link ("Codex Bağlama") — the shape that crosses the IPC bridge.
*
* Types only, so the renderer can import them without pulling main-process
* code into its program. See codex-link.ts for what the fields mean in action.
*/
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
export type CodexLinkError =
| 'autostart-failed'
| 'bridge-config-failed'
| 'bridge-missing'
| 'bridge-start-failed'
| 'config-write-failed'
| 'settings-write-failed'
export interface CodexLinkStatus {
enabled: boolean
/** Codex's `config.toml` exists on this machine. */
codexFound: boolean
/** The IDE's own Python environment exists (first-run setup finished). */
venvReady: boolean
/** Codex already uses another bridge; nothing of its setup is touched. */
foreign: boolean
bridgeRunning: boolean
/** From the bridge's status endpoint; null while it is not answering. */
ideDurum: CodexLinkIdeState | null
ideModel: null | number
ozet: boolean | null
lastError: CodexLinkError | null
}
export interface CodexLinkPatch {
enabled?: boolean
/** "Sync now": re-run the apply without changing the preference. */
sync?: boolean
}
+419
View File
@@ -0,0 +1,419 @@
import { describe, expect, it } from 'vitest'
import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link'
import {
CODEX_LINK_LINE,
CODEX_LINK_REFRESH_URL,
CODEX_LINK_STATUS_URL,
CODEX_LINK_STOP_URL,
codexBridgeArgs,
codexBridgeConfig
} from './codex-link-config'
const PATHS: CodexLinkPaths = {
settingsFile: 'D:\\ide\\codex-baglama.json',
bridgeHome: 'D:\\ide\\codex-bridge',
hermesHome: 'D:\\ide\\agent',
bridgeScript: 'D:\\app\\codex-bridge\\bridge.py',
pythonw: 'D:\\ide\\agent\\venv\\Scripts\\pythonw.exe',
python: 'D:\\ide\\agent\\venv\\Scripts\\python.exe',
codexConfig: 'D:\\home\\.codex\\config.toml'
}
const BRIDGE_JSON = `${PATHS.bridgeHome}\\bridge.json`
const BACKUP = `${PATHS.codexConfig}.aiturk-yedek`
const STATUS_OK = { ok: true, surum: '2', ide_durum: 'tamam', ide_model: 7, ozet: true }
const USER_CONFIG = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
interface World {
/** What the machine looks like; mutated by the fake effects. */
files: Map<string, string>
/** Is something answering on the bridge port? */
listening: boolean
/** Version the listening bridge reports before any spawn; a spawn brings the current one. */
staleVersion: null | string
/** Does a spawn bring the bridge up? `false` = it never answers. */
spawnWorks: boolean
/** Spawned bridge only starts answering after this many status probes. */
probesUntilUp: number
autostart: null | string
regFails: boolean
/** Every observable effect, in order. */
events: string[]
clock: number
}
function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {}) {
const world: World = {
autostart: null,
clock: 0,
events: [],
files: new Map([
[PATHS.codexConfig, USER_CONFIG],
[PATHS.bridgeScript, '# bridge'],
[PATHS.pythonw, ''],
[PATHS.python, '']
]),
listening: false,
staleVersion: null,
probesUntilUp: 0,
regFails: false,
spawnWorks: true,
...patch
}
let spawned = false
let probesSinceSpawn = 0
const deps: CodexLinkDeps = {
paths: PATHS,
exists: file => world.files.has(file),
readText: file => world.files.get(file) ?? null,
writeTextAtomic: (file, text) => {
world.events.push(`write ${file}`)
world.files.set(file, text)
},
backupOnce: (source, target) => {
if (!world.files.has(target)) {
world.events.push(`backup ${target}`)
world.files.set(target, world.files.get(source) ?? '')
}
},
spawnBridge: (command, args, env) => {
world.events.push(`spawn ${command} ${JSON.stringify(args)} ${JSON.stringify(env)}`)
spawned = true
probesSinceSpawn = 0
world.staleVersion = null
},
request: async (method, url) => {
if (method === 'GET' && url === CODEX_LINK_STATUS_URL) {
if (spawned && world.spawnWorks && !world.listening) {
probesSinceSpawn += 1
if (probesSinceSpawn > world.probesUntilUp) {
world.listening = true
}
}
if (world.listening) {
world.events.push('status ok')
return world.staleVersion ? { ...STATUS_OK, surum: world.staleVersion } : STATUS_OK
}
return null
}
if (method === 'POST' && url === CODEX_LINK_REFRESH_URL) {
world.events.push('refresh')
return world.listening ? STATUS_OK : null
}
if (method === 'POST' && url === CODEX_LINK_STOP_URL) {
world.events.push('stop')
if (!world.listening) {
return null
}
world.listening = false
return { ok: true }
}
throw new Error(`unexpected request ${method} ${url}`)
},
setAutostart: async command => {
world.events.push('autostart set')
if (world.regFails) {
throw new Error('reg add exited 1')
}
world.autostart = command
},
clearAutostart: async () => {
world.events.push('autostart clear')
world.autostart = null
},
sleep: async ms => {
world.clock += ms
},
now: () => world.clock,
log: () => undefined
}
return { link: createCodexLink(deps), world }
}
const writes = (world: World) => world.events.filter(event => event.startsWith('write '))
const spawns = (world: World) => world.events.filter(event => event.startsWith('spawn '))
// Effects that change the machine (a status probe does not).
const effects = (world: World) => world.events.filter(event => event !== 'status ok')
describe('applyCodexLink', () => {
it('touches nothing when Codex is not installed', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.codexConfig)
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, codexFound: false, bridgeRunning: false, lastError: null })
expect(effects(world)).toEqual([])
expect(world.autostart).toBeNull()
})
it('touches nothing when Codex already uses another bridge', async () => {
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n[features]\n'
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.codexConfig, foreign)
const status = await link.apply({ sync: true })
expect(status).toMatchObject({ enabled: true, codexFound: true, foreign: true, bridgeRunning: true })
expect(effects(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
})
it('waits for the IDE setup: no venv, no changes', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.pythonw)
world.files.delete(PATHS.python)
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, codexFound: true, venvReady: false, foreign: false })
expect(effects(world)).toEqual([])
})
it('brings everything up in the safe order', async () => {
const { link, world } = createWorld({ probesUntilUp: 2 })
const status = await link.apply()
expect(status).toEqual({
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 7,
ozet: true,
lastError: null
})
const env = { AITURK_CODEX_BRIDGE_HOME: PATHS.bridgeHome, HERMES_HOME: PATHS.hermesHome }
const args = codexBridgeArgs({
bridgeHome: PATHS.bridgeHome,
bridgeScript: PATHS.bridgeScript,
hermesHome: PATHS.hermesHome
})
// bridge.json → start → verified answer → config.toml (backed up) → login item
expect(world.events).toEqual([
`write ${BRIDGE_JSON}`,
`spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`,
'status ok',
`backup ${BACKUP}`,
`write ${PATHS.codexConfig}`,
'autostart set'
])
expect(JSON.parse(world.files.get(BRIDGE_JSON) ?? '')).toEqual(codexBridgeConfig())
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
expect(world.files.get(BACKUP)).toBe(USER_CONFIG)
expect(world.autostart).toBe(
`"${PATHS.pythonw}" "${PATHS.bridgeScript}" --home "${PATHS.bridgeHome}" --hermes-home "${PATHS.hermesHome}"`
)
})
it('never points Codex at a bridge that did not come up', async () => {
const { link, world } = createWorld({ spawnWorks: false })
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, bridgeRunning: false, lastError: 'bridge-start-failed' })
expect(spawns(world)).toHaveLength(1)
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.files.has(BACKUP)).toBe(false)
expect(world.autostart).toBeNull()
expect(writes(world)).toEqual([`write ${BRIDGE_JSON}`])
// The start poll is bounded by its one budget.
expect(world.clock).toBe(CODEX_BRIDGE_START_BUDGET_MS)
})
it('takes its own line back out when the bridge can no longer be started', async () => {
const { link, world } = createWorld({ spawnWorks: false })
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
const status = await link.apply()
expect(status.lastError).toBe('bridge-start-failed')
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
})
it('does not start a second bridge when one is already listening', async () => {
const { link, world } = createWorld({ listening: true })
await link.apply()
await link.apply({ sync: true })
expect(spawns(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
// The config is written once; the login item is refreshed on every apply.
expect(writes(world).filter(event => event === `write ${PATHS.codexConfig}`)).toHaveLength(1)
expect(world.events.filter(event => event === 'autostart set')).toHaveLength(2)
})
it('replaces a bridge left running by an older IDE build', async () => {
const { link, world } = createWorld({ listening: true, staleVersion: '0' })
const status = await link.apply()
expect(world.events.indexOf('stop')).toBeGreaterThanOrEqual(0)
expect(spawns(world)).toHaveLength(1)
expect(world.events.indexOf('stop')).toBeLessThan(world.events.findIndex(event => event.startsWith('spawn')))
expect(status.bridgeRunning).toBe(true)
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('asks a running bridge to re-read the IDE session on every apply', async () => {
const { link, world } = createWorld({ listening: true })
await link.apply({ sync: true })
expect(world.events).toContain('refresh')
expect(spawns(world)).toEqual([])
})
it('never overwrites the first backup', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(BACKUP, 'the original original')
await link.apply()
expect(world.files.get(BACKUP)).toBe('the original original')
})
it('falls back to python.exe when pythonw.exe is missing', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.pythonw)
await link.apply()
expect(spawns(world)[0]).toContain(`spawn ${PATHS.python} `)
expect(world.autostart?.startsWith(`"${PATHS.python}" `)).toBe(true)
})
it('reports a missing bridge script instead of starting anything', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.bridgeScript)
const status = await link.apply()
expect(status.lastError).toBe('bridge-missing')
expect(effects(world)).toEqual([])
})
it('surfaces a registry failure but keeps the working link', async () => {
const { link, world } = createWorld({ listening: true, regFails: true })
const status = await link.apply()
expect(status).toMatchObject({ bridgeRunning: true, lastError: 'autostart-failed' })
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('undoes everything when turned off', async () => {
const { link, world } = createWorld({ listening: true })
await link.apply()
world.events.length = 0
const status = await link.apply({ enabled: false })
expect(status).toMatchObject({ enabled: false, codexFound: true, bridgeRunning: false, lastError: null })
expect(world.events).toEqual([
`write ${PATHS.settingsFile}`,
`write ${PATHS.codexConfig}`,
'autostart clear',
'stop'
])
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' })
// Byte for byte what the user had before the feature touched it.
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull()
expect(world.listening).toBe(false)
})
it('stays off across applies and comes back when turned on', async () => {
const { link, world } = createWorld()
world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}')
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
expect(spawns(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: true,
kapsam: 'ide',
not: 'ours'
})
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('does not stop a bridge that serves a foreign config when turned off', async () => {
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n'
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.codexConfig, foreign)
const status = await link.apply({ enabled: false })
expect(status).toMatchObject({ enabled: false, foreign: true, bridgeRunning: true })
expect(world.events).not.toContain('stop')
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
expect(world.listening).toBe(true)
})
it('runs concurrent applies one after another', async () => {
const { link, world } = createWorld({ probesUntilUp: 3 })
const [first, second] = await Promise.all([link.apply(), link.apply({ enabled: false })])
expect(first.enabled).toBe(true)
expect(second).toMatchObject({ enabled: false, bridgeRunning: false })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull()
})
})
describe('getStatus', () => {
it('reads without changing anything', async () => {
const { link, world } = createWorld({ listening: true })
const status = await link.getStatus()
expect(status).toMatchObject({
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 7,
ozet: true
})
expect(effects(world)).toEqual([])
})
it('reports a bridge that is not answering', async () => {
const { link, world } = createWorld()
expect(await link.getStatus()).toMatchObject({ bridgeRunning: false, ideDurum: null, ideModel: null, ozet: null })
expect(effects(world)).toEqual([])
})
})
+536
View File
@@ -0,0 +1,536 @@
/**
* Codex Link ("Codex Bağlama") — decisions and side effects.
*
* When the feature is on and OpenAI Codex is installed on this machine, the IDE
* runs a small local Python bridge, points Codex's `config.toml` at it and
* registers the bridge as a login item, so Codex keeps working with the IDE's
* models while the IDE itself is closed. Turning it off undoes all three.
*
* Ordering is the safety property: Codex cannot work at all while its config
* points at a dead address, so the config line is written only AFTER the bridge
* has answered, and it is taken back out if the bridge cannot be brought up.
*
* Electron-free on purpose. `createCodexLink` takes every effect as a
* dependency (files, spawn, HTTP, registry) so the ordering above is provable
* in a unit test; `createNodeCodexLinkDeps` is the real wiring main.ts uses.
* The text transformations live in `codex-link-config.ts`.
*/
import { execFile, spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import {
addCodexLinkLine,
CODEX_BRIDGE_VERSION,
CODEX_LINK_BACKUP_SUFFIX,
CODEX_LINK_LAUNCHER_NAME,
CODEX_LINK_REFRESH_URL,
CODEX_LINK_RUN_KEY,
CODEX_LINK_RUN_VALUE,
CODEX_LINK_STATUS_URL,
CODEX_LINK_STOP_URL,
codexAutostartPlan,
codexBridgeArgs,
type CodexBridgeLaunch,
inspectCodexConfig,
parseCodexLinkSettings,
removeCodexLinkLine,
serializeCodexBridgeConfig,
serializeCodexLinkSettings
} from './codex-link-config'
import type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
import { hiddenWindowsChildOptions } from './windows-child-options'
export type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
export interface CodexLinkPaths {
/** `<data root>/codex-baglama.json` */
settingsFile: string
/** `<data root>/codex-bridge` — the bridge's data folder. */
bridgeHome: string
hermesHome: string
/** `<resources>/codex-bridge/bridge.py` */
bridgeScript: string
/** Windowless interpreter, preferred. */
pythonw: string
/** Console interpreter, used hidden when pythonw is absent. */
python: string
/** `<CODEX_HOME or ~/.codex>/config.toml` */
codexConfig: string
}
export interface CodexLinkDeps {
paths: CodexLinkPaths
exists: (file: string) => boolean
/** null when the file is missing; throws when it cannot be read losslessly. */
readText: (file: string) => null | string
/** tmp + rename; creates the parent folder. */
writeTextAtomic: (file: string, text: string) => void
/** Copy `source` to `target` unless `target` already exists. */
backupOnce: (source: string, target: string) => void
/** Start the bridge detached and hidden; it must outlive the IDE. */
spawnBridge: (command: string, args: string[], env: Record<string, string>) => void
/** Parsed JSON body, or null when nothing answered within `budgetMs`. */
request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise<unknown>
setAutostart: (command: string) => Promise<void>
clearAutostart: () => Promise<void>
sleep: (ms: number) => Promise<void>
now: () => number
log: (message: string) => void
}
// The single wait this feature owns: how long a freshly spawned bridge gets to
// answer its status endpoint before the apply gives up.
export const CODEX_BRIDGE_START_BUDGET_MS = 10_000
const START_POLL_INTERVAL_MS = 500
// A probe of an already-running (or absent) local bridge. Loopback answers or
// refuses at once; the bound only keeps Settings from hanging on a wedged port.
const PROBE_BUDGET_MS = 2_000
interface BridgeProbe {
ideDurum: CodexLinkIdeState | null
ideModel: null | number
ozet: boolean | null
running: boolean
surum: null | string
}
const NOT_RUNNING: BridgeProbe = { ideDurum: null, ideModel: null, ozet: null, running: false, surum: null }
function readProbe(body: unknown): BridgeProbe {
if (!body || typeof body !== 'object' || (body as { ok?: unknown }).ok !== true) {
return NOT_RUNNING
}
const reply = body as { ide_durum?: unknown; ide_model?: unknown; ozet?: unknown; surum?: unknown }
const state = reply.ide_durum
return {
ideDurum: state === 'tamam' || state === 'kapali' || state === 'anahtar-yok' ? state : null,
ideModel: typeof reply.ide_model === 'number' && Number.isFinite(reply.ide_model) ? reply.ide_model : null,
ozet: typeof reply.ozet === 'boolean' ? reply.ozet : null,
running: true,
surum: typeof reply.surum === 'string' ? reply.surum : null
}
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export interface CodexLink {
/** Side-effect free: reads the preference, the files and the status endpoint. */
getStatus: () => Promise<CodexLinkStatus>
/** Persist `patch.enabled` (if given), make the machine match, report. */
apply: (patch?: CodexLinkPatch) => Promise<CodexLinkStatus>
}
export function createCodexLink(deps: CodexLinkDeps): CodexLink {
const { paths } = deps
let lastError: CodexLinkError | null = null
let queue: Promise<unknown> = Promise.resolve()
const launch: CodexBridgeLaunch = {
bridgeHome: paths.bridgeHome,
bridgeScript: paths.bridgeScript,
hermesHome: paths.hermesHome
}
const fail = (code: CodexLinkError, error?: unknown) => {
lastError = code
deps.log(`[codex-link] ${code}${error === undefined ? '' : `: ${describe(error)}`}`)
}
const readSettings = () => {
try {
return parseCodexLinkSettings(deps.readText(paths.settingsFile))
} catch {
return parseCodexLinkSettings(null)
}
}
// The interpreter to run the bridge with, or null while the IDE's own
// first-run setup has not produced the venv yet.
const interpreter = () =>
deps.exists(paths.pythonw) ? paths.pythonw : deps.exists(paths.python) ? paths.python : null
// The owner of Codex's base URL. An unreadable config counts as foreign for
// the purpose of "do not touch": we only edit what we could read back intact.
const readCodexConfig = (): { found: boolean; owner: 'absent' | 'foreign' | 'ours'; text: null | string } => {
if (!deps.exists(paths.codexConfig)) {
return { found: false, owner: 'absent', text: null }
}
try {
const text = deps.readText(paths.codexConfig)
return text === null
? { found: false, owner: 'absent', text: null }
: { found: true, owner: inspectCodexConfig(text), text }
} catch (error) {
deps.log(`[codex-link] config.toml could not be read: ${describe(error)}`)
return { found: true, owner: 'foreign', text: null }
}
}
const probe = async (budgetMs = PROBE_BUDGET_MS): Promise<BridgeProbe> => {
try {
return readProbe(await deps.request('GET', CODEX_LINK_STATUS_URL, budgetMs))
} catch {
return NOT_RUNNING
}
}
const statusFrom = (
enabled: boolean,
config: { found: boolean; owner: string },
bridge: BridgeProbe
): CodexLinkStatus => ({
enabled,
codexFound: config.found,
venvReady: interpreter() !== null,
foreign: config.owner === 'foreign',
bridgeRunning: bridge.running,
ideDurum: bridge.ideDurum,
ideModel: bridge.ideModel,
ozet: bridge.ozet,
lastError
})
const removeOurLine = (text: null | string): boolean => {
if (text === null) {
return true
}
const edit = removeCodexLinkLine(text)
if (!edit.changed) {
return true
}
try {
deps.writeTextAtomic(paths.codexConfig, edit.text)
return true
} catch (error) {
fail('config-write-failed', error)
return false
}
}
// Off: take back everything we put there. Each step is independent — a
// failure in one must not leave the others in place.
const tearDown = async (config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
removeOurLine(config.text)
try {
await deps.clearAutostart()
} catch (error) {
fail('autostart-failed', error)
}
// A foreign config means whatever listens on the port serves someone
// else's setup; stopping it would break a bridge we do not own.
if (config.owner === 'foreign') {
return probe()
}
try {
// No answer means nothing of ours is listening; leave it alone.
return (await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)) === null ? probe() : NOT_RUNNING
} catch {
return probe()
}
}
const startBridge = async (python: string): Promise<BridgeProbe> => {
try {
deps.spawnBridge(python, codexBridgeArgs(launch), {
AITURK_CODEX_BRIDGE_HOME: paths.bridgeHome,
HERMES_HOME: paths.hermesHome
})
} catch (error) {
deps.log(`[codex-link] bridge spawn failed: ${describe(error)}`)
return NOT_RUNNING
}
const deadline = deps.now() + CODEX_BRIDGE_START_BUDGET_MS
for (;;) {
const remaining = deadline - deps.now()
if (remaining <= 0) {
return NOT_RUNNING
}
const result = await probe(Math.min(PROBE_BUDGET_MS, remaining))
if (result.running) {
return result
}
await deps.sleep(Math.min(START_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now())))
}
}
// On: bridge.json → bridge up (verified) → config.toml → login item.
const setUp = async (python: string, config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
if (!deps.exists(paths.bridgeScript)) {
fail('bridge-missing', paths.bridgeScript)
return probe()
}
try {
deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig())
} catch (error) {
fail('bridge-config-failed', error)
return probe()
}
let bridge = await probe()
if (bridge.running && bridge.surum !== CODEX_BRIDGE_VERSION) {
// A bridge from an older IDE build is still listening: replace it.
try {
await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)
} catch {
// It stops answering once it exits; the start below verifies the new one.
}
await deps.sleep(START_POLL_INTERVAL_MS * 2)
bridge = NOT_RUNNING
}
if (bridge.running) {
// Already up: have it re-read the IDE session and model list now (a sign-in
// since its last periodic refresh would otherwise show up minutes later).
try {
const refreshed = readProbe(await deps.request('POST', CODEX_LINK_REFRESH_URL, CODEX_BRIDGE_START_BUDGET_MS))
bridge = refreshed.running ? refreshed : bridge
} catch {
// The status it already gave stays valid.
}
} else {
bridge = await startBridge(python)
}
if (!bridge.running) {
fail('bridge-start-failed')
// Fail open: a line left over from an earlier run would now point Codex
// at a dead address. Without it Codex simply talks to OpenAI directly.
removeOurLine(config.text)
return bridge
}
const edit = addCodexLinkLine(config.text ?? '')
if (edit.changed) {
try {
deps.backupOnce(paths.codexConfig, `${paths.codexConfig}${CODEX_LINK_BACKUP_SUFFIX}`)
deps.writeTextAtomic(paths.codexConfig, edit.text)
} catch (error) {
fail('config-write-failed', error)
return bridge
}
}
// Rewritten on every apply: an IDE update can move the bridge script.
try {
const plan = codexAutostartPlan(python, launch, path.join(paths.bridgeHome, CODEX_LINK_LAUNCHER_NAME))
if (!plan) {
throw new Error('the bridge command does not fit a Run value')
}
if (plan.launcher) {
deps.writeTextAtomic(plan.launcher.path, plan.launcher.source)
}
await deps.setAutostart(plan.command)
} catch (error) {
fail('autostart-failed', error)
}
return bridge
}
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
lastError = null
if (typeof patch?.enabled === 'boolean') {
try {
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled))
} catch (error) {
// The preference did not stick, so do not act on it either: what the
// machine does must match what the file says after a restart.
fail('settings-write-failed', error)
}
}
const { enabled } = readSettings()
const config = readCodexConfig()
if (!enabled) {
return statusFrom(enabled, config, await tearDown(config))
}
const python = interpreter()
if (!config.found || !python || config.owner === 'foreign') {
return statusFrom(enabled, config, await probe())
}
return statusFrom(enabled, config, await setUp(python, config))
}
return {
getStatus: async () => statusFrom(readSettings().enabled, readCodexConfig(), await probe()),
// One apply at a time: two interleaved runs could write the config line
// from one while the other is tearing the bridge down.
apply: patch => {
const next = queue.then(() => run(patch))
queue = next.catch(() => undefined)
return next
}
}
}
// ── Real dependencies ───────────────────────────────────────────────────────
function writeTextAtomic(file: string, text: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, text, 'utf8')
fs.renameSync(tmp, file)
} catch (error) {
fs.rmSync(tmp, { force: true })
throw error
}
}
// Decode as UTF-8 and insist the bytes survive the round trip. A config in
// another encoding would be corrupted by a rewrite, so refuse to return it.
function readTextLossless(file: string): null | string {
let bytes: Buffer
try {
bytes = fs.readFileSync(file)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
const text = bytes.toString('utf8')
if (!Buffer.from(text, 'utf8').equals(bytes)) {
throw new Error('file is not valid UTF-8')
}
return text
}
function runReg(args: string[]): Promise<{ code: number; stderr: string }> {
const reg = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe')
return new Promise(resolve => {
execFile(reg, args, hiddenWindowsChildOptions({ encoding: 'utf8' }), (error, _stdout, stderr) => {
const exit = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as any).code : 1) : 0
resolve({ code: exit, stderr: String(stderr || (error ? error.message : '')).trim() })
})
})
}
async function requestJson(method: 'GET' | 'POST', url: string, budgetMs: number): Promise<unknown> {
try {
const response = await fetch(url, { method, signal: AbortSignal.timeout(Math.max(1, budgetMs)) })
return response.ok ? await response.json() : null
} catch {
return null
}
}
export function createNodeCodexLinkDeps(paths: CodexLinkPaths, log: (message: string) => void): CodexLinkDeps {
return {
paths,
exists: file => fs.existsSync(file),
readText: readTextLossless,
writeTextAtomic,
backupOnce: (source, target) => {
if (!fs.existsSync(target)) {
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL)
}
},
spawnBridge: (command, args, env) => {
fs.mkdirSync(paths.bridgeHome, { recursive: true })
// Detached with no stdio and unref'd: the bridge is deliberately NOT a
// child the IDE cleans up on quit — Codex depends on it staying alive.
const child = spawn(
command,
args,
hiddenWindowsChildOptions({
cwd: paths.bridgeHome,
detached: true,
env: { ...process.env, ...env },
stdio: 'ignore'
}) as any
)
child.on('error', error => log(`[codex-link] bridge process error: ${error.message}`))
child.unref()
},
request: requestJson,
setAutostart: async command => {
const result = await runReg([
'add',
CODEX_LINK_RUN_KEY,
'/v',
CODEX_LINK_RUN_VALUE,
'/t',
'REG_SZ',
'/d',
command,
'/f'
])
if (result.code !== 0) {
throw new Error(`reg add exited ${result.code}: ${result.stderr}`)
}
},
clearAutostart: async () => {
// `reg delete` fails on a value that is not there; absent is already the
// state we want, so look first.
const present = await runReg(['query', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE])
if (present.code !== 0) {
return
}
const result = await runReg(['delete', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/f'])
if (result.code !== 0) {
throw new Error(`reg delete exited ${result.code}: ${result.stderr}`)
}
},
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
now: () => Date.now(),
log
}
}
@@ -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
}
+285 -6
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'
@@ -6,8 +6,10 @@ import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import tls from 'node:tls'
import { pathToFileURL } from 'node:url'
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,
@@ -30,8 +32,21 @@ 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 { createCodexLink, createNodeCodexLinkDeps } from './codex-link'
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'
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
@@ -2062,6 +2077,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) => {
@@ -5220,6 +5239,22 @@ async function ensureRuntime(backend) {
)
}
// Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the
// managed CPython does not ship. Place the bundled Microsoft CRT beside the
// managed python.exe before the backend imports anything. Never blocks boot:
// only the voice features depend on it.
if (IS_WINDOWS) {
const vcRuntime = ensureWindowsVcRuntime({
platform: process.platform,
venvRoot: VENV_ROOT,
sourceDir: IS_PACKAGED
? path.join(process.resourcesPath, 'vc-runtime', 'x64')
: path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64')
})
rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`)
}
backend.command = getVenvPython(VENV_ROOT)
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
updateBootProgress({
@@ -5230,6 +5265,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
}
@@ -6766,7 +6806,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) {
@@ -13052,6 +13092,9 @@ async function startHermes() {
// accumulated count of the resolved episode.
bootstrapRepairAttempt = 0
// The local backend is up, so the venv the Codex bridge runs on exists.
syncCodexLinkOnBoot()
return {
baseUrl,
mode: 'local',
@@ -17143,6 +17186,72 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
return applyQuickEntrySettings(next)
})
// Codex Link ("Codex Bağlama"): lets the OpenAI Codex on this machine use the
// IDE's models through a small local bridge. Main owns the preference file, the
// bridge process, Codex's config line and the login item; Settings reads the
// resulting state and asks for changes over IPC. Windows only (the login item is
// a Run-key value). All decisions live in electron/codex-link.ts.
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
const codexLink = createCodexLink(
createNodeCodexLinkDeps(
{
settingsFile: path.join(AITURK_IDE_DATA_ROOT, 'codex-baglama.json'),
bridgeHome: path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
hermesHome: HERMES_HOME,
bridgeScript: IS_PACKAGED
? path.join(process.resourcesPath, 'codex-bridge', 'bridge.py')
: path.join(APP_ROOT, 'assets', 'codex-bridge', 'bridge.py'),
pythonw: path.join(VENV_ROOT, 'Scripts', 'pythonw.exe'),
python: getVenvPython(VENV_ROOT),
codexConfig: path.join(process.env.CODEX_HOME || path.join(app.getPath('home'), '.codex'), 'config.toml')
},
rememberLog
)
)
let codexLinkBootSynced = false
// Once per launch, after the local backend is ready. Never allowed to affect
// startup: it is not awaited and every failure ends in the log.
function syncCodexLinkOnBoot() {
if (!IS_WINDOWS || codexLinkBootSynced) {
return
}
codexLinkBootSynced = true
void codexLink.apply().catch(error => rememberLog(`[codex-link] boot sync failed: ${error?.message ?? error}`))
}
// Only the primary window's top-level frame may read or change the link.
function assertCodexLinkSender(event) {
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
if (
!IS_WINDOWS ||
!contents ||
event.sender !== contents ||
(event.senderFrame && event.senderFrame !== contents.mainFrame)
) {
throw new Error('codex-link:unavailable')
}
}
ipcMain.handle('aiturk:codex-link:get', async event => {
assertCodexLinkSender(event)
return codexLink.getStatus()
})
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
assertCodexLinkSender(event)
return codexLink.apply({
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
sync: patch?.sync === true
})
})
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
// owns the one prompt-submit path, and forwarding keeps it that way. The
// payload is `{ target, text }` — target routing (current chat / a picked
@@ -17213,6 +17322,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`).
@@ -17570,8 +17726,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())
@@ -17717,6 +17876,10 @@ if (!isPrimaryInstance) {
app.exit(0)
} else {
app.on('second-instance', (_event, argv) => {
if (process.platform === 'win32' && argv.includes('--aiturk-mobile')) {
openMobileConnectionWindow()
return
}
const url = _extractDeepLink(argv)
if (url) {
@@ -17740,7 +17903,118 @@ app.on('open-url', (event, url) => {
handleDeepLink(url)
})
let mobileConnectionWindow: BrowserWindow | null = null
function openMobileConnectionWindow() {
if (mobileConnectionWindow && !mobileConnectionWindow.isDestroyed()) {
mobileConnectionWindow.show()
mobileConnectionWindow.focus()
return
}
mobileConnectionWindow = createMobileWindow({
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'),
@@ -17751,7 +18025,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
+161
View File
@@ -0,0 +1,161 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
import { join } from 'node:path'
import { ChromeGatewayClient } from './chrome-bridge.mjs'
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
// Journal stores only hashes and delivery state, no prompt, token, or result.
export class DeliveryJournal {
constructor(directory) { this.directory = directory; this.writes = Promise.resolve() }
async reserve(deviceId, taskId, envelope) {
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
await mkdir(this.directory, { recursive: true })
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
const hash = digest(envelope)
try {
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
return { file, hash, fresh: true }
} catch (error) {
if (error.code !== 'EEXIST') throw error
let previous
try { previous = JSON.parse(await readFile(file, 'utf8')) }
catch { return { file, hash, fresh: false, state: 'unknown' } }
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
return { file, hash, fresh: false, state: previous.state }
}
}
async accepted(reservation) {
return this.update(reservation, 'accepted')
}
update(reservation, state) {
const operation = this.writes.then(async () => {
const previous = JSON.parse(await readFile(reservation.file, 'utf8'))
if (['complete', 'error', 'interrupted'].includes(previous.state)) return
const temporary = `${reservation.file}.tmp`
await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true })
await rename(temporary, reservation.file)
})
this.writes = operation.catch(() => {})
return operation
}
}
export class MobileGatewayClient extends ChromeGatewayClient {
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
super(resolveBackend, emit, webSocket)
this.deviceId = deviceId
this.journal = journal
this.authorize = authorize
this.approval = null
this.model = null
this.chain = Promise.resolve()
this.tasks = new Map()
}
onEvent(event) {
if (['task.start', 'task.complete'].includes(event?.type)) {
const task = this.tasks.get(event.payload?.task_id)
if (!task || task.runtimeId !== event.session_id || task.terminal) return
const state = event.type === 'task.start' ? 'running' : event.payload.status
if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return
task.terminal = state !== 'running'
task.state = state
if (task.terminal) this.journal.update(task.reservation, state).catch(() => {})
this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state,
text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' })
return
}
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
if (event.type === 'approval.request') {
this.approval = this.approvalView(event.payload)
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
return
}
if (event.type === 'message.complete') this.approval = null
super.onEvent(event)
}
approvalView(payload) {
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
// Retain full backend-redacted operation; never approve a clipped preview.
if (payload.command.length > 48000) return null
return { requestId: payload.request_id, command: payload.command,
reason: typeof payload.reason === 'string' ? payload.reason : '',
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
}
handle(message) {
const result = this.chain.then(() => this.perform(message))
this.chain = result.catch(() => {})
return result
}
async perform(message) {
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
await this.authorize()
const params = message.params || {}
if (message.action === 'session.open') {
const list = await super.handle({ id: message.id, action: 'sessions.list' })
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
const result = await super.handle(message)
this.model = result.model
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
this.approval = this.approvalView(resumed.pending_approval)
return { ...result, approval: this.approval }
}
if (message.action === 'task.submit') {
const capabilities = await this.rpc('gateway.capabilities', {})
if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.')
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
// A model change by the desktop between selection and delivery fails closed.
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
model: params.model, text: params.text, intent: params.intent }
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
const correlation = digest([this.deviceId, params.taskId])
if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId,
runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' })
if (!reservation.fresh) {
// Recover a terminal event missed while the phone/companion was closed.
// Never resubmit even if the bounded replay window no longer has it.
const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 })
for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params)
await this.journal.writes
const state = this.tasks.get(correlation).state
return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) }
}
await this.authorize()
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true,
client_task_id: correlation })
await this.journal.accepted(reservation)
if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted'
return { taskId: params.taskId, state: 'accepted' }
}
if (message.action === 'approval.respond') {
if (this.selected?.storedId !== params.sessionId || !this.approval ||
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
request_id: params.requestId, choice: params.choice, all: false })
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
this.approval = null
return { resolved: true }
}
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
throw new Error('Bu mobil işlem desteklenmiyor.')
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
throw new Error('Önce konuşmayı seçin.')
return super.handle(message)
}
}
@@ -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 }
}
}
+169
View File
@@ -0,0 +1,169 @@
// "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.',
},
},
}
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() }
}
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'
}
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 }
+34
View File
@@ -0,0 +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 '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>
+52
View File
@@ -0,0 +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 { BrowserWindow, ipcMain } from 'electron'
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 } })
window.setMenu(null)
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', event => event.preventDefault())
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', () => {
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
)
+5
View File
@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('mobileHost', {
call: (action, params) => ipcRenderer.invoke('aiturk-mobile', action, params),
onEvent: callback => ipcRenderer.on('aiturk-mobile-event', (_event, value) => callback(value)),
})
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>
+52
View File
@@ -0,0 +1,52 @@
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
import { join } from 'node:path'
import { ASSISTANT_INSTRUCTION, VOICE_TOOLS } from '../../mobile/src/behavior.mjs'
export class MobileVault {
constructor(directory, safeStorage) { this.directory = directory; this.safeStorage = safeStorage }
async load() {
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
try {
const data = await readFile(join(this.directory, 'mobile-secrets.bin'))
return JSON.parse(this.safeStorage.decryptString(data))
} catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Mobil anahtar kasası açılamadı.') }
}
async save(value) {
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
const encrypted = this.safeStorage.encryptString(JSON.stringify(value))
await mkdir(this.directory, { recursive: true })
const file = join(this.directory, 'mobile-secrets.bin')
await writeFile(file + '.tmp', encrypted, { mode: 0o600 })
await rename(file + '.tmp', file)
}
}
export const LIVE_MODEL = 'gemini-3.1-flash-live-preview'
export class LiveTokenIssuer {
constructor(vault, fetcher = fetch, now = Date.now) { this.vault = vault; this.fetcher = fetcher; this.now = now; this.last = new Map() }
async issue(deviceId) {
const now = this.now()
if (now - (this.last.get(deviceId) ?? -Infinity) < 30000) throw new Error('Yeni ses bağlantısı için 30 saniye bekleyin.')
this.last.set(deviceId, now)
const secrets = await this.vault.load()
if (!secrets.googleKey) throw new Error('Önce kendi Google AI Studio anahtarınızı ekleyin.')
let response
try {
response = await this.fetcher('https://generativelanguage.googleapis.com/v1beta/auth_tokens', {
method: 'POST', headers: { 'x-goog-api-key': secrets.googleKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ uses: 1, expireTime: new Date(now + 10 * 60000).toISOString(),
newSessionExpireTime: new Date(now + 60000).toISOString(),
bidiGenerateContentSetup: { model: `models/${LIVE_MODEL}`,
generationConfig: { responseModalities: ['AUDIO'] },
systemInstruction: { parts: [{ text: ASSISTANT_INSTRUCTION }] },
tools: [{ functionDeclarations: VOICE_TOOLS }],
inputAudioTranscription: {}, outputAudioTranscription: {} } }),
redirect: 'error', signal: AbortSignal.timeout(20000) })
} catch { throw new Error('Google ses erişimine ulaşılamadı.') }
if (!response.ok) throw new Error(response.status === 429 ? 'Google kotası veya istek sınırı doldu. AI Studio üzerinden kontrol edin.'
: response.status === 401 || response.status === 403 ? 'Google anahtarı veya ses modeli erişimi geçersiz.' : 'Google ses erişimi oluşturulamadı.')
const data = await response.json()
if (typeof data.name !== 'string' || !data.name.startsWith('auth_tokens/')) throw new Error('Google geçerli kısa ömürlü erişim döndürmedi.')
return { token: data.name, model: LIVE_MODEL, expiresAt: new Date(now + 10 * 60000).toISOString() }
}
}
+22
View File
@@ -278,6 +278,28 @@ 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')
},
// "Codex Bağlama" (electron/codex-link.ts). Windows only: absent elsewhere,
// so Settings does not offer a page that cannot do anything.
...(process.platform === 'win32'
? {
codexLink: {
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
}
}
: {}),
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) }
}
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, test } from 'vitest'
import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime'
const here = path.dirname(fileURLToPath(import.meta.url))
const roots: string[] = []
function tempRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-'))
roots.push(root)
return root
}
function sha256(data: Buffer) {
return crypto.createHash('sha256').update(data).digest('hex')
}
// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir
// whose manifest describes the given files.
function fixture(files: Record<string, { data: string; ownedByPython?: boolean }>) {
const root = tempRoot()
const home = path.join(root, 'python home')
const venv = path.join(root, 'venv')
const source = path.join(root, 'bundle')
for (const dir of [home, venv, source]) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(path.join(home, 'python.exe'), 'exe')
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`)
const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => {
const bytes = Buffer.from(data)
fs.writeFileSync(path.join(source, name), bytes)
return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) }
})
fs.writeFileSync(
path.join(source, 'manifest.json'),
JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries })
)
return { home, venv, source }
}
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('non-Windows platforms are left untouched', () => {
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), {
status: 'skipped',
reason: 'not windows'
})
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
})
test('readPythonHome parses pyvenv.cfg home with spaces', () => {
const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } })
assert.equal(readPythonHome(venv), home)
assert.equal(readPythonHome(path.join(venv, 'missing')), null)
})
test('missing CRT files are copied beside python.exe and verified', () => {
const { home, venv, source } = fixture({
'msvcp140.dll': { data: 'msvcp' },
'msvcp140_1.dll': { data: 'msvcp1' }
})
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] })
assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1')
assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), [])
// Second launch: nothing to do.
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), {
status: 'ok',
pythonHome: home,
copied: []
})
})
test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => {
const { home, venv, source } = fixture({
'msvcp140.dll': { data: 'good' },
'vcruntime140.dll': { data: 'bundled', ownedByPython: true }
})
fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale')
fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned')
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] })
assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good')
assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned')
})
test('a tampered bundled file is refused and nothing is written', () => {
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } })
fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered')
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
assert.equal(result.status, 'failed')
assert.match((result as { error: string }).error, /does not match its manifest digest/)
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
})
test('an unresolvable python home fails without throwing', () => {
const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n')
assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed')
})
test('the shipped CRT set matches its manifest and covers the voice wheels', () => {
const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64')
const manifest = loadVcRuntimeManifest(sourceDir)
const names = manifest.files.map(file => file.name)
for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) {
assert.ok(names.includes(required), `${required} must be bundled`)
}
for (const file of manifest.files) {
const bytes = fs.readFileSync(path.join(sourceDir, file.name))
assert.equal(bytes.length, file.size, file.name)
assert.equal(sha256(bytes), file.sha256, file.name)
}
})
test('the Windows package ships the CRT set as an extra resource', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
const resources = pkg.build.win.extraResources
assert.ok(
resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'),
'win.extraResources must carry vc-runtime/x64'
)
})
test('ensureRuntime places the CRT before handing the venv python to the backend', () => {
const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
const start = source.indexOf('async function ensureRuntime(')
const body = source.slice(start, source.indexOf('\nfunction ', start + 1))
const ensureIdx = body.indexOf('ensureWindowsVcRuntime(')
const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)')
assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime')
assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen')
})
+168
View File
@@ -0,0 +1,168 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
// ── Windows VC++ runtime for the managed Python ─────────────────────────────
//
// Voice features load native wheels (onnxruntime for the wake word,
// ctranslate2 for local speech-to-text) that link against msvcp140.dll and
// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a
// Windows install without the system-wide VC++ Redistributable those imports
// fail with "DLL load failed / The specified module could not be found".
//
// The desktop package carries the Microsoft-signed VC143 CRT set and copies it
// next to the managed python.exe before every local backend start. That
// directory is the process image directory, which Windows searches first for
// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can
// never win. Existing installs are healed on their next launch.
export interface VcRuntimeFile {
name: string
size: number
sha256: string
// Files the managed CPython already ships. Never overwrite an existing copy:
// the interpreter owns it and replacing a loaded DLL is not possible anyway.
ownedByPython?: boolean
}
export interface VcRuntimeManifest {
schemaVersion: 1
version: string
architecture: string
files: VcRuntimeFile[]
}
export type VcRuntimeResult =
| { status: 'skipped'; reason: string }
| { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] }
| { status: 'failed'; pythonHome: string | null; copied: string[]; error: string }
const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i
const SHA256_RE = /^[0-9a-f]{64}$/
export function readPythonHome(venvRoot: string): string | null {
let text: string
try {
text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
} catch {
return null
}
for (const line of text.split(/\r?\n/)) {
const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line)
if (match) {
return match[1]
}
}
return null
}
export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest {
const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8'))
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) {
throw new Error('invalid VC++ runtime manifest')
}
for (const file of manifest.files) {
if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) {
throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`)
}
}
return manifest
}
function sha256File(filePath: string): string {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
}
function matches(filePath: string, file: VcRuntimeFile): boolean {
try {
return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256
} catch {
return false
}
}
function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) {
const source = path.join(sourceDir, file.name)
if (!matches(source, file)) {
throw new Error(`bundled ${file.name} does not match its manifest digest`)
}
const target = path.join(pythonHome, file.name)
const temp = `${target}.aiturk-${process.pid}.tmp`
try {
fs.copyFileSync(source, temp)
if (!matches(temp, file)) {
throw new Error(`copied ${file.name} failed digest verification`)
}
fs.renameSync(temp, target)
} finally {
fs.rmSync(temp, { force: true })
}
}
export function ensureWindowsVcRuntime({
platform,
venvRoot,
sourceDir
}: {
platform: NodeJS.Platform
venvRoot: string
sourceDir: string
}): VcRuntimeResult {
if (platform !== 'win32') {
return { status: 'skipped', reason: 'not windows' }
}
const pythonHome = readPythonHome(venvRoot)
if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) {
return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' }
}
const copied: string[] = []
try {
const manifest = loadVcRuntimeManifest(sourceDir)
for (const file of manifest.files) {
const target = path.join(pythonHome, file.name)
if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) {
continue
}
installFile(sourceDir, pythonHome, file)
copied.push(file.name)
}
} catch (error) {
return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) }
}
return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied }
}
export function describeVcRuntimeResult(result: VcRuntimeResult): string {
if (result.status === 'skipped') {
return `skipped (${result.reason})`
}
if (result.status === 'failed') {
return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` +
(result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '')
}
return result.status === 'repaired'
? `installed ${result.copied.join(', ')} into ${result.pythonHome}`
: `verified in ${result.pythonHome}`
}
+22 -2
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.10",
"version": "1.0.0-beta.21",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
@@ -290,7 +290,27 @@
"nsis",
"msi"
],
"signAndEditExecutable": false
"signAndEditExecutable": false,
"extraResources": [
{
"from": "assets/windows/vc-runtime/x64",
"to": "vc-runtime/x64",
"filter": [
"*.dll",
"manifest.json"
]
},
{
"from": "assets/codex-bridge",
"to": "codex-bridge",
"filter": [
"**/*.py",
"**/*.pyd",
"**/LICENSE",
"!test_*.py"
]
}
]
},
"linux": {
"category": "Development",
+24 -1
View File
@@ -10,9 +10,10 @@
// `electron` and `node-pty` are external (provided by the runtime / staged
// separately via stage-native-deps).
import { build } from 'esbuild'
import { createRequire } from 'node:module'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdirSync } from 'node:fs'
import { mkdirSync, cpSync, existsSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
@@ -63,3 +64,25 @@ await build({
logLevel: 'info',
})
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
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 })
// 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
// look them up along Node's resolution path from apps/mobile instead of
// assuming apps/mobile/node_modules exists.
const mobileRequire = createRequire(resolve(root, '../mobile/package.json'))
function mobilePackageDir(name) {
for (const base of mobileRequire.resolve.paths(name) ?? []) {
const dir = resolve(base, name)
if (existsSync(resolve(dir, 'package.json'))) return dir
}
throw new Error(`${name} is not installed for apps/mobile; run npm ci at the repository root`)
}
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
@@ -0,0 +1,193 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { CodexLinkStatus } from '../../../electron/codex-link-types'
import { CodexLinkSettings } from './codex-link-settings'
const notifyError = vi.hoisted(() => vi.fn())
vi.mock('@/store/notifications', () => ({ notifyError }))
const RUNNING: CodexLinkStatus = {
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 12,
ozet: true,
lastError: null
}
const get = vi.fn<() => Promise<CodexLinkStatus>>()
const set = vi.fn<(patch: { enabled?: boolean; sync?: boolean }) => Promise<CodexLinkStatus>>()
function renderPage(locale: 'en' | 'tr' = 'tr') {
return render(
<I18nProvider configClient={null} initialLocale={locale}>
<CodexLinkSettings />
</I18nProvider>
)
}
function statusTexts() {
return screen.getAllByRole('listitem').map(item => item.textContent)
}
beforeEach(() => {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { get, set } }
})
afterEach(() => {
cleanup()
vi.resetAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('Codex Link settings', () => {
it('explains the feature and shows a healthy link in Turkish', async () => {
get.mockResolvedValue(RUNNING)
renderPage()
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulundu.', 'Köprü çalışıyor.', "Codex'e 12 model eklendi."])
expect(
screen.getByText(
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur."
)
).toBeTruthy()
expect(screen.getByText("Codex'te yeni modelleri görmek için Codex'i yeniden açın.")).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
expect(set).not.toHaveBeenCalled()
})
it('renders in English for other locales', async () => {
get.mockResolvedValue(RUNNING)
renderPage('en')
expect(await screen.findByText('The bridge is running.')).toBeTruthy()
expect(statusTexts()).toEqual([
'Codex was found on this computer.',
'The bridge is running.',
'12 models added to Codex.'
])
expect(screen.getByRole('button', { name: 'Sync now' })).toBeTruthy()
})
it('says only that Codex is missing when it is not installed', async () => {
get.mockResolvedValue({ ...RUNNING, codexFound: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
expect(await screen.findByText('Bu bilgisayarda Codex bulunamadı.')).toBeTruthy()
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulunamadı.'])
})
it('says another bridge is in charge and does not claim ours is running', async () => {
get.mockResolvedValue({ ...RUNNING, foreign: true })
renderPage()
expect(await screen.findByText(/başka bir köprü kullanıyor/)).toBeTruthy()
expect(statusTexts()).toEqual([
'Bu bilgisayarda Codex bulundu.',
'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.'
])
})
it('says it is waiting for the IDE setup', async () => {
get.mockResolvedValue({ ...RUNNING, venvReady: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
expect(await screen.findByText('IDE kurulumu tamamlanınca etkinleşecek.')).toBeTruthy()
expect(screen.queryByText('Köprü çalışmıyor.')).toBeNull()
})
it('asks for a sign-in when the bridge has no IDE session', async () => {
get.mockResolvedValue({ ...RUNNING, ideDurum: 'anahtar-yok', ideModel: 0 })
renderPage()
expect(await screen.findByText('IDE oturumu bulunamadı, giriş yapın.')).toBeTruthy()
expect(screen.queryByText(/model eklendi/)).toBeNull()
})
it('shows a stopped bridge together with the last error', async () => {
get.mockResolvedValue({
...RUNNING,
bridgeRunning: false,
ideDurum: null,
ideModel: null,
lastError: 'bridge-start-failed'
})
renderPage()
expect(await screen.findByText('Köprü çalışmıyor.')).toBeTruthy()
expect(screen.getByText('Köprü başlatılamadı; Codex ayarına dokunulmadı.')).toBeTruthy()
})
it('turns the link off through the bridge and adopts the reply', async () => {
get.mockResolvedValue(RUNNING)
set.mockResolvedValue({ ...RUNNING, enabled: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
await screen.findByText('Köprü çalışıyor.')
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
expect(set).toHaveBeenCalledWith({ enabled: false })
expect(await screen.findByText('Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('false')
})
it('rolls the switch back and reports when the change fails', async () => {
get.mockResolvedValue(RUNNING)
set.mockRejectedValue(new Error('codex-link:unavailable'))
renderPage()
await screen.findByText('Köprü çalışıyor.')
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
await waitFor(() => expect(notifyError).toHaveBeenCalledWith(expect.any(Error), 'Codex Bağlama değiştirilemedi.'))
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByText('Köprü çalışıyor.')).toBeTruthy()
})
it('syncs on demand and keeps the button disabled while it runs', async () => {
get.mockResolvedValue({ ...RUNNING, bridgeRunning: false, ideDurum: null, ideModel: null })
let finish: (status: CodexLinkStatus) => void = () => undefined
set.mockReturnValue(
new Promise<CodexLinkStatus>(resolve => {
finish = resolve
})
)
renderPage()
await screen.findByText('Köprü çalışmıyor.')
fireEvent.click(screen.getByRole('button', { name: 'Şimdi eşitle' }))
expect(set).toHaveBeenCalledTimes(1)
expect(set).toHaveBeenCalledWith({ sync: true })
const pending = screen.getByRole('button', { name: 'Eşitleniyor…' }) as HTMLButtonElement
expect(pending.disabled).toBe(true)
fireEvent.click(pending)
expect(set).toHaveBeenCalledTimes(1)
finish(RUNNING)
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
expect((screen.getByRole('button', { name: 'Şimdi eşitle' }) as HTMLButtonElement).disabled).toBe(false)
})
it('offers nothing to click where the desktop bridge is absent', () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
renderPage()
expect(screen.getByText('Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.')).toBeTruthy()
expect(screen.queryByRole('switch')).toBeNull()
expect(get).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link'
import { triggerHaptic } from '@/lib/haptics'
import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
type Tone = 'muted' | 'ok' | 'warn'
interface StatusLine {
id: string
text: string
tone: Tone
}
/**
* What the user needs to know, top-down, stopping at the first fact that makes
* the rest moot (no Codex → nothing else to say; another bridge → ours is not
* involved; setup pending → the bridge cannot be running yet).
*/
export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] {
const lines: StatusLine[] = []
const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : []
if (!status.codexFound) {
return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error]
}
lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' })
if (status.foreign) {
return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error]
}
if (!status.enabled) {
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
}
if (!status.venvReady) {
return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error]
}
if (!status.bridgeRunning) {
return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error]
}
lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' })
if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
}
return [...lines, ...error]
}
const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const
const TONE_CLASS: Record<Tone, string> = {
muted: 'text-(--ui-text-tertiary)',
ok: 'text-(--ui-text-secondary)',
warn: 'text-amber-500/90'
}
// Phone-sized hit area around the compact switch: the visible control keeps the
// design-system size, the label that wraps it is the 44px target.
const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end'
/**
* Settings → Codex Link. Main owns everything real (the preference file, the
* bridge, Codex's config, the login item); this page reads the state it reports
* and asks for changes, adopting whatever comes back as the truth.
*/
export function CodexLinkSettings() {
const copy = useCodexLinkCopy()
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink
const [status, setStatus] = useState<CodexLinkStatus | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null)
// A slow initial read must not overwrite the answer to a newer change.
const generation = useRef(0)
useEffect(() => {
if (!bridge) {
return
}
const ticket = generation.current
bridge.get().then(
next => {
if (generation.current === ticket) {
setStatus(next)
}
},
() => {
if (generation.current === ticket) {
setLoadFailed(true)
}
}
)
}, [bridge])
const change = useCallback(
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
if (!bridge || busy) {
return
}
const before = status
generation.current += 1
setBusy(kind)
setLoadFailed(false)
// The switch moves at once; main's reply (or the rollback) has the last word.
if (typeof patch.enabled === 'boolean' && before) {
setStatus({ ...before, enabled: patch.enabled })
}
try {
setStatus(await bridge.set(patch))
} catch (error) {
setStatus(before)
notifyError(error, copy.saveFailed)
} finally {
setBusy(null)
}
},
[bridge, busy, copy.saveFailed, status]
)
if (!bridge) {
return (
<SettingsContent>
<EmptyState description={copy.unavailable} title={copy.title} />
</SettingsContent>
)
}
const lines = status ? codexLinkStatusLines(status, copy) : []
return (
<SettingsContent>
<SettingsSection icon={Link} title={copy.title}>
<ListRow
action={
<label className={TOUCH_TARGET}>
<Switch
aria-label={copy.title}
checked={status?.enabled ?? true}
disabled={!status || busy !== null}
onCheckedChange={enabled => {
triggerHaptic('selection')
void change({ enabled }, 'toggle')
}}
/>
</label>
}
description={copy.description}
title={copy.title}
/>
{!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && (
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
{copy.loadFailed}
</p>
)}
{status && (
<ListRow
below={
<ul aria-live="polite" className="mt-1.5 grid gap-1.5">
{lines.map(line => {
const Icon = TONE_ICON[line.tone]
return (
<li
className={cn(
'flex min-w-0 items-start gap-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
TONE_CLASS[line.tone]
)}
data-tone={line.tone}
key={line.id}
>
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">{line.text}</span>
</li>
)
})}
</ul>
}
title={copy.statusTitle}
wide
/>
)}
<ListRow
action={
<Button
className="min-h-11 w-full @2xl:w-auto"
disabled={!status || busy !== null}
onClick={() => {
triggerHaptic('open')
void change({ sync: true }, 'sync')
}}
type="button"
variant="secondary"
>
<RefreshCw />
{busy === 'sync' ? copy.syncing : copy.sync}
</Button>
}
description={copy.syncDesc}
title={copy.syncTitle}
/>
<p className="pt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartNote}
</p>
</SettingsSection>
</SettingsContent>
)
}
@@ -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">
+39 -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,8 @@ 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 { useCodexLinkCopy } from '@/i18n/codex-link'
import { triggerHaptic } from '@/lib/haptics'
import {
Archive,
@@ -18,6 +21,7 @@ import {
Info,
Keyboard,
KeyRound,
Link,
Package,
RefreshCw,
Search,
@@ -44,6 +48,7 @@ import { SKILLS_ROUTE } from '../routes'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { BillingSettings } from './billing'
import { CodexLinkSettings } from './codex-link-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
@@ -66,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'notifications',
'billing',
'codex-link',
'plugins',
'sessions',
'about'
@@ -73,6 +79,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const aiturkCopy = useAiturkCopy()
const codexLinkCopy = useCodexLinkCopy()
const navigate = useNavigate()
const { hash, pathname, search } = useLocation()
@@ -249,6 +257,34 @@ 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))
}
}
]
: []),
// "Codex Bağlama": present only where the desktop bridge offers it
// (Windows). A stale ?tab=codex-link elsewhere renders its own notice.
...(window.hermesDesktop?.codexLink
? [
{
active: activeView === 'codex-link',
icon: Link,
id: 'codex-link',
label: codexLinkCopy.nav,
onSelect: () => setActiveView('codex-link')
}
]
: []),
{
active: activeView === 'keybinds',
icon: Keyboard,
@@ -302,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
@@ -421,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : activeView === 'codex-link' ? (
<CodexLinkSettings />
) : (
<SessionsSettings />
)
+1
View File
@@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView =
| 'about'
| 'billing'
| 'codex-link'
| 'connections'
| 'gateway'
| 'keybinds'
@@ -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>
+34
View File
@@ -1,6 +1,7 @@
import type { GatewayWsUrlResult } from '@hermes/shared'
import type { TranslucencyState } from '@hermes/shared/translucency'
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
import type { PoolLimits } from '../electron/pool-limits'
import type { WakeIndicatorState } from './lib/wake-indicator'
@@ -323,6 +324,25 @@ 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 }>
}
/** "Codex Bağlama" (electron/codex-link.ts): lets the Codex on this machine
* use the IDE's models. `get` only reads; `set` applies and returns the
* state that actually resulted. Windows only. */
codexLink?: {
get: () => Promise<CodexLinkStatus>
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
}
/** 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 +1232,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',
+80
View File
@@ -0,0 +1,80 @@
import type { CodexLinkError } from '../../electron/codex-link-types'
import { useI18n } from './context'
// Copy for Settings → Codex Link ("Codex Bağlama"). Kept beside the other
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
// every other locale.
const en = {
nav: 'Codex Link',
title: 'Codex Link',
description:
'Adds the models and combos from AITURK IDE to the Codex on this computer and keeps the conversation context when you switch between models.',
unavailable: 'Codex Link is only available in the Windows desktop app.',
loadFailed: 'Could not read the Codex Link status.',
saveFailed: 'Could not change Codex Link.',
statusTitle: 'Status',
off: 'Codex Link is off. Codex runs with its own settings.',
codexFound: 'Codex was found on this computer.',
codexMissing: 'Codex was not found on this computer.',
foreign: 'Codex on this computer uses another bridge; its settings were not touched.',
venvPending: 'It will turn on once the IDE setup is complete.',
bridgeRunning: 'The bridge is running.',
bridgeStopped: 'The bridge is not running.',
models: (count: number) => (count === 1 ? '1 model added to Codex.' : `${count} models added to Codex.`),
signIn: 'No IDE session was found; sign in.',
errors: {
'autostart-failed': 'Could not set the bridge to start with Windows.',
'bridge-config-failed': 'Could not write the bridge settings.',
'bridge-missing': 'The bridge files are missing from this installation.',
'bridge-start-failed': 'The bridge could not be started; the Codex settings were left unchanged.',
'config-write-failed': 'Could not write the Codex settings file.',
'settings-write-failed': 'Could not save this setting.'
} satisfies Record<CodexLinkError, string>,
syncTitle: 'Sync now',
syncDesc: 'Checks the bridge and the Codex settings again.',
sync: 'Sync now',
syncing: 'Syncing…',
restartNote: 'Reopen Codex to see new models there.'
}
const tr: typeof en = {
nav: 'Codex Bağlama',
title: 'Codex Bağlama',
description:
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur.",
unavailable: 'Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.',
loadFailed: 'Codex Bağlama durumu okunamadı.',
saveFailed: 'Codex Bağlama değiştirilemedi.',
statusTitle: 'Durum',
off: 'Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.',
codexFound: 'Bu bilgisayarda Codex bulundu.',
codexMissing: 'Bu bilgisayarda Codex bulunamadı.',
foreign: 'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.',
venvPending: 'IDE kurulumu tamamlanınca etkinleşecek.',
bridgeRunning: 'Köprü çalışıyor.',
bridgeStopped: 'Köprü çalışmıyor.',
models: (count: number) => `Codex'e ${count} model eklendi.`,
signIn: 'IDE oturumu bulunamadı, giriş yapın.',
errors: {
'autostart-failed': 'Köprü Windows açılışında başlayacak şekilde ayarlanamadı.',
'bridge-config-failed': 'Köprü ayarları yazılamadı.',
'bridge-missing': 'Köprü dosyaları bu kurulumda bulunamadı.',
'bridge-start-failed': 'Köprü başlatılamadı; Codex ayarına dokunulmadı.',
'config-write-failed': 'Codex ayar dosyası yazılamadı.',
'settings-write-failed': 'Bu ayar kaydedilemedi.'
},
syncTitle: 'Şimdi eşitle',
syncDesc: 'Köprüyü ve Codex ayarını yeniden denetler.',
sync: 'Şimdi eşitle',
syncing: 'Eşitleniyor…',
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
}
export type CodexLinkCopy = typeof en
export function useCodexLinkCopy(): CodexLinkCopy {
const { locale } = useI18n()
return locale === 'tr' ? tr : en
}
+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',
+13
View File
@@ -0,0 +1,13 @@
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',
'electron/codex-link*.test.ts'
]
}
})
+1
View File
@@ -0,0 +1 @@
*.bat text eol=crlf
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
android/.gradle/
android/local.properties
android/**/build/
*.keystore
*.jks
test-results/
+198
View File
@@ -0,0 +1,198 @@
# AITURK Mobil — Android geliştirme sürümü 0.1.4
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
Google Live ve gerçek telefon/ağ doğrulaması henüz yapılmadı. Canlı
turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi.
## Çalışan ve test edilen parçalar
- Capacitor 8.5.1 Android projesi; Türkçe erişilebilir kurulum ve yazılı kullanım.
- Tarayıcıda mevcut AITURK hesabıyla kısa kod onayı; uygulamaya parola girilmez.
Dar kapsamlı mobil oturum Windows/Android kasasında tutulur. Mevcut hesap API
anahtarı gelişmiş seçenek olarak korunur. Bu yeni giriş henüz canlıya dağıtılmadı.
Telefonun eşleşmesini hedef bilgisayar kendi cihaz tokenıyla onaylar.
- WebRTC DTLS/SCTP veri kanalı; büyük Unicode mesajları sınırlı parçalara bölme.
- Mevcut IDE `/api/sessions` ve JSON-RPC adaptörü. Konuşma ve seçili model
gönderimden önce doğrulanır. Model listesi icat edilmez.
- Görevden önce diske yazılan tekrar gönderim kaydı. Kabul sonucu belirsizse
ikinci kez çalıştırılmaz. Telefonun bekleyen gönderimi Android kasasında kalır.
- Gerçek IDE onay kimliği ve redakte edilmiş tam komutla yalnız `once`/`deny`.
Sürekli/genel izin, sudo/UAC veya ses modeli üzerinden onay yolu yoktur.
- Google anahtarı Windows `safeStorage`, hesap cihaz sırrı Android Keystore
AES-GCM kullanır. Android yedekleme ve cihazlar arası veri taşıma kapatılır.
- Anahtardan kısa ömürlü, model/araç/sistem talimatı kısıtlı Google Live token
üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu.
- Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder
düğmesi ve ayrı IDE durdur düğmesi vardır.
- Google kullanım bildirimleri ücretli liste tarifesiyle tahmin edilir; her
isteğin tekrar faturalandırılan geçmişi de sayılır. Ayrıntısı eksik tokenlar
için fiyat aralığı gösterilir. Bu fatura, kredi veya ücretsiz kota sorgusu değildir.
- Kullanıcı her görüşme için 1–10 dakika ve 0,01–10 USD tahmini durdurma sınırı
seçer (varsayılan 10 dakika / 0,25 USD). Üst tahmin sınıra ulaşırsa, süre
dolarsa veya 120 saniye kullanım bildirimi gelmezse yalnız Google bağlantısı
kapanır. Otomatik yeniden bağlanılmaz. Her yeni oturumda sayaç sıfırlanır;
bu günlük/aylık bütçe veya Google tarafında kesin harcama tavanı değildir.
## Geliştirme kurulumu
Node 22+, JDK 21 ve Android API 36 gerekir. Bu çalışma sırasında araçlar
`%LOCALAPPDATA%/aiturk-mobile-tools` altına kuruldu; kullanıcı sistem PATH'i
veya yönetici ayarları değiştirilmedi.
```powershell
# Bu klasörde; kök depodaki kurulumu etkilemeyen Android kurulumu
npm ci --workspaces=false --ignore-scripts
npm test --workspaces=false
npm run android:sync --workspaces=false
# JAVA_HOME ve ANDROID_HOME kendi araç yollarınızı göstermeli
.\android\gradlew.bat -p android assembleDebug assembleRelease lintDebug
```
`app-debug.apk` geliştirici anahtarıyla imzalanır. `app-release-unsigned.apk`
ü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ı.
## Uyumlu IDE ve bağlantı servisi
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
Derlenmiş IDE'yi `--aiturk-mobile` parametresiyle açın. IDE zaten açıksa parametre
mobil bağlantı penceresini açar; çalışmakta olan konuşma kapatılmaz.
Mobil pencere açılmadan uzaktan erişim etkinleşmez.
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayın. Bilgisayarda
“Telefon için QR oluştur” seçip telefonda kamerayla veya QR resmiyle okuyun.
4. Bilgisayarda “QR ile gelen telefonu getir” seçin; telefonun gösterdiği kısa
kodu girip açıkça onaylayın. Uzun kimliği elle yazmak gerekmez.
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
Veri/ücret bilgisini kabul ettikten sonra Google görüşmesini başlatın.
Gateway eş değişikliği `portal/routes_mobile.py` ve `router_gateway.py` içindedir.
Mevcut member guard korunur. Üretim kurulumu ve TURN koşulları eş gateway
deposunun `docs/aiturk-mobile-operations.md` dosyasında açıklanır.
## Doğrulama ve sınırlar
0.1.4-dev (Android versionCode 5), 12 Eylül 2026: 41 mobil test geçti;
tarayıcı uygulama testi, yerel sertifika/DTLS testi ve Electron IPC/Windows kasa
testi geçti. Telefon kimliği QR resminin onaysız içe alınması ve değiştirilmiş
cihaz kimliklerinin reddi test edildi. `assembleDebug lintDebug` başarılı,
uygulama lint sonucu 0 hata / 17 uyarı. Debug APK imzası doğrulandı ve kimlik,
taşıma, uygulama, davet, gizlilik ve ana sayfa dosyaları kaynakla karşılaştırıldı.
APK SHA-256: `94835E223B71BE36AFB26686D3EBC79D8976514FFDB4E7CDFC189ACE7E2F12A5`.
Bu APK geliştirici imzalıdır; üretim/gerçek Android kabulü değildir.
12 Eylül 2026 yerel doğrulaması: `npm test` 37/37 geçti. Ses başlangıcı
beklerken iptal ve kapatılmış oturumu yeniden açma regresyonları önce başarısız
testlerle gösterildi, sonra düzeltildi. `npm run android:sync` ve
`gradlew.bat assembleDebug lintDebug` başarılı; uygulama lint sonucu 0 hata,
17 uyarı. `scripts/browser-smoke.mjs` gerçek DTLS veri kanalında 240.000
karakter aktarımını ve sentetik Google kullanım sınırını doğruladı; Google'a
gerçek çağrı yapmadı. APK içindeki `voice.mjs` kaynakla birebir karşılaştırıldı.
Debug imzası `apksigner verify` ile doğrulandı; bu üretim imzası değildir.
APK SHA-256: `F5DB098DF0F1C59B92F274969FCAD926BCB2DD25AF823533A983085A18BFB966`.
Bu kontrolde `adb devices -l` bağlı cihaz göstermedi; fiziksel telefon kabulü
ve canlı yayın hâlâ tamamlanmadı.
`npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları.
`scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı,
240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi.
`scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu;
yalnız sentetik bir test anahtarı kullanır.
`src/peer-identity.mjs` cihaz kimliğine göre kalıcı standart RTCCertificate
kaydını, atomik ilk oluşturmayı, kayıtlı parmak iziyle yüklemeyi ve SDP kimlik
kontrolünü uygular. Anahtar eksik veya geçersizse yükleme yeni sertifika üretmez.
Bilgisayar QR daveti sürüm 2 ile sertifika parmak izini taşır; telefon bunu
eşleştirme kaydına alır ve SDP uygulanmadan önce kontrol eder. Bilgisayar
bağlantıda kayıtlı sertifikasını kullanır. Eski sürüm 1 QR ve kimliği olmayan
elle eşleştirmeler veri bağlantısı açamaz; yeni QR ile yeniden eşleştirme gerekir.
Telefon da eşleştirmede kendi sertifikasını oluşturur ve bağlantıda aynı
sertifikayı yükler. Kullanıcı telefondaki kimlik belgesini bilgisayardaki
ilgili alana aktarır; `phone.approve` belgeyi eşleştirme kimliği ve sunucunun
onayladığı cihaz kimliğiyle karşılaştırıp Windows kasasına kaydeder. IDE hem
gelen bağlantı listesinde hem `channel.answer` yolunda kayıtlı telefon
parmak izini kontrol eder; renderer SDP'yi uygulamadan önce tekrar doğrular.
İlk kayıt için yerel açık onay gerekir; mevcut telefon kimliği sessizce
değiştirilemez. Telefon kimliğini QR olarak da gösterir. Kullanıcı ekran
görüntüsünü kendi bilgisayarına aktararak “Telefon kimliği QR resmi” alanından
okutabilir; eşleştirme kimliği ve belge doldurulur, fakat otomatik onay verilmez.
Kısa kod ve ayrı onay düğmesi gereklidir; elle belge aktarma yolu da korunur.
`node scripts/certificate-smoke.mjs`: 12 Eylül 2026'da izole Edge bağlamında
yerel WebRTC sertifikasının IndexedDB'ye yazılıp sayfa yenilendikten sonra
aynı parmak iziyle geri alınması, geri alınan sertifikayla gerçek DTLS veri
aktarımı ve yanlış SDP parmak izinde DTLS'nin `failed` durumuna geçmesi
doğrulandı. Test artık gerçek `peer-identity.mjs` modülünü kullanıyor; eşzamanlı
dört oluşturma çağrısının tek kimlikte birleşmesi, kayıp kaydın ve yanlış
parmak izinin reddi de geçti. Tarayıcı uygulama testi ayrıca QR ile eşlenen
bilgisayar yerine farklı sertifikalı SDP geldiğinde görünür kimlik hatası
verildiğini ve hiçbir IDE isteği gönderilmediğini doğruladı. Electron IPC testi
Windows kasasındaki parmak iziyle sertifikanın pencere yenilemesinden sonra
yüklenmesini ve sessiz kimlik değişiminin reddini doğruladı. Bu testlerde portal
ve Google gerçek servisleri kullanılmadı; canlı karşılıklı kimlik kabulü değildir.
Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır:
```bash
scripts/run_tests.sh tests/tui_gateway/test_mobile_runtime_integration.py -q
```
Bu test gerçek IDE HTTP, WebSocket, konuşma veritabanı, prompt.submit ve
message.complete yolunu çalıştırır. Ücretli çıkarım servisi yerine yerel
deterministik sağlayıcı kullanılır. Aynı görev ikinci defa gönderildiğinde IDE
geçmişinde yalnız bir kullanıcı görevi oluşur. Gerçek Google/iş modeli kalite
ve maliyet testi olarak sayılmaz.
12 Eylül 2026, 0.1.4 kaynak doğrulaması: zorunlu `scripts/run_tests.sh`
sarmalayıcısıyla `test_compute_host.py`, `test_compute_host_phase1.py` ve
`test_mobile_runtime_integration.py` birlikte çalıştırıldı. 3 dosyada 16 test
geçti, 0 başarısız; süre 32,9 saniye. Windows venv yönlendirici sürecinin
bildirdiği PID, gerçek süreç soy ağacı üzerinden doğrulanır. Bu koşu yalnız
yerel sentetik sağlayıcı kullanır, telefon donanımı veya Google kabulü değildir.
Henüz tamamlanmayanlar:
- Karşılıklı cihaz kimliğinin gerçek Android ve paketlenmiş IDE arasında,
yeniden başlatma/iptal/anahtar kaybı senaryolarıyla uçtan uca kabulü.
İki yönün kodu var; tarayıcı ve Electron testleri gerçek cihaz kabulünün
yerine geçmez. Telefon QR çıktısının pikselden geri okunması ve Electron'da
QR resminin açık onay vermeden alanlara aktarılması test edildi; gerçek
telefon ekran görüntüsü aktarımı henüz denenmedi.
- Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi.
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
- Giriş ve QR artık kodlandı: 9 HTTP/DB testi, 27 mobil testi, gerçek QR pikseli
okuyan (hesap/claim yanıtları sahte) tarayıcı akışı geçti. Fiziksel Android
kamera/giriş kabulü ve farklı ağ bağlantısı henüz yapılmadı.
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
- Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev
sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç
bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz.
- Maliyet tahmini/ayarlanabilir oturum sınırı uygulandı: 35 Node testi ve
derlenmiş uygulamanın gerçek WebRTC, sentetik IDE/Google yanıtlı sınır akışı
geçti. Gerçek Google faturasına karşı karşılaştırma henüz yapılmadı.
- Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış
kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin
sinyalleşme hizmetine güven sınırı gizlenmez.
- Üretim imzası, kapalı beta kabulü ve turkservis.online indirme yayını.
Capacitor CLI'nin kullanılmayan iOS araç zincirinde `xcode → uuid` üzerinden
3 moderate geliştirme bağımlılığı uyarısı mevcut; Android runtime audit sonucu
ayrıca kontrol edilir. Bunları gizlemek için `npm audit fix --force` uygulanmadı.
## Resmî dayanaklar
- https://capacitorjs.com/docs/getting-started/environment-setup
- https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens
- https://ai.google.dev/api/live
- https://ai.google.dev/gemini-api/docs/pricing
- https://github.com/coturn/coturn/blob/master/README.turnserver
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "online.turkservis.aiturk.mobile"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "online.turkservis.aiturk.mobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
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.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:usesCleartextTraffic="false"
android:icon="@drawable/aiturk_icon"
android:label="@string/app_name"
android:roundIcon="@drawable/aiturk_icon"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
</manifest>
@@ -0,0 +1,11 @@
package online.turkservis.aiturk.mobile;
import com.getcapacitor.BridgeActivity;
import android.os.Bundle;
public class MainActivity extends BridgeActivity {
@Override public void onCreate(Bundle savedInstanceState) {
registerPlugin(MobileSecretsPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,120 @@
package online.turkservis.aiturk.mobile;
import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.net.ssl.HttpsURLConnection;
import java.net.URL;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.atomic.AtomicInteger;
/** Standard Android Keystore AEAD storage. Key never leaves AndroidKeyStore. */
@CapacitorPlugin(name = "MobileSecrets")
public class MobileSecretsPlugin extends Plugin {
private static final String ALIAS = "aiturk.mobile.account.v1";
private final AtomicInteger requests = new AtomicInteger();
@PluginMethod public void openLogin(PluginCall call) {
String url = call.getString("url", "");
if (!url.matches("https://turkservis\\.online/account/aiturk/mobile-login/[0-9a-f-]{36}")) {
call.reject("Geçersiz giriş adresi."); return;
}
try {
getActivity().startActivity(new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)));
call.resolve();
} catch (Exception error) { call.reject("Tarayıcı açılamadı."); }
}
@PluginMethod public void request(PluginCall call) {
String path = call.getString("path", "");
String method = call.getString("method", "GET");
String body = call.getString("body", "");
if (!path.matches("/v1/aiturk/[A-Za-z0-9/_.-]+") || path.contains("..") ||
!method.matches("GET|POST|DELETE") || body.length() > 65536) {
call.reject("Geçersiz hesap isteği."); return;
}
if (requests.incrementAndGet() > 8) { requests.decrementAndGet(); call.reject("Bekleyen hesap istekleri var."); return; }
new Thread(() -> {
HttpsURLConnection connection = null;
try {
connection = (HttpsURLConnection) new URL("https://turkservis.online" + path).openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(20000); connection.setReadTimeout(20000);
connection.setRequestMethod(method);
JSObject headers = call.getObject("headers", new JSObject());
for (String name : new String[] {"Authorization", "X-Aiturk-Device-ID", "X-Aiturk-Device-Token"}) {
String value = headers.getString(name, "");
if (value.length() > 1024 || value.contains("\r") || value.contains("\n")) throw new IllegalArgumentException();
if (!value.isEmpty()) connection.setRequestProperty(name, value);
}
connection.setRequestProperty("Content-Type", "application/json");
if (!body.isEmpty()) {
connection.setDoOutput(true);
try (java.io.OutputStream out = connection.getOutputStream()) { out.write(body.getBytes(StandardCharsets.UTF_8)); }
}
int status = connection.getResponseCode();
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
InputStream input = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (input != null) try (InputStream stream = input) {
byte[] buffer = new byte[4096]; int count;
while ((count = stream.read(buffer)) != -1) {
if (bytes.size() + count > 262144) throw new IllegalStateException();
bytes.write(buffer, 0, count);
}
}
JSObject result = new JSObject(); result.put("status", status);
result.put("body", bytes.toString(StandardCharsets.UTF_8.name())); call.resolve(result);
} catch (Exception error) { call.reject("Hesap bağlantısı kurulamadı."); }
finally { if (connection != null) connection.disconnect(); requests.decrementAndGet(); }
}, "aiturk-account").start();
}
private SecretKey key() throws Exception {
KeyStore store = KeyStore.getInstance("AndroidKeyStore"); store.load(null);
if (!store.containsAlias(ALIAS)) {
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(new KeyGenParameterSpec.Builder(ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build());
generator.generateKey();
}
return (SecretKey) store.getKey(ALIAS, null);
}
@PluginMethod public void save(PluginCall call) {
String value = call.getString("value");
if (value == null || value.length() > 65536) { call.reject("Geçersiz hesap kaydı."); return; }
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key());
String encrypted = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP) + ":" +
Base64.encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP);
boolean saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE)
.edit().putString("account", encrypted).commit();
if (!saved) throw new IllegalStateException();
call.resolve();
} catch (Exception error) { call.reject("Android güvenli kasasına kaydedilemedi."); }
}
@PluginMethod public void load(PluginCall call) {
try {
String saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE).getString("account", null);
JSObject result = new JSObject();
if (saved != null) {
String[] parts = saved.split(":", 2);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)));
result.put("value", new String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), StandardCharsets.UTF_8));
}
call.resolve(result);
} catch (Exception error) { call.reject("Android güvenli kasası açılamadı."); }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more