Compare commits

...
Author SHA1 Message Date
yilsemandClaude Opus 5.5 88e660768c feat(codex-bridge): keep the gateway's short "9R" mark in Codex model names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 19:27:22 +03:00
yilsemandClaude Opus 5.5 bfd0ff196d feat(codex-bridge): add the models of 9Router's providers to Codex (beta.23)
Codex only received combos and the models named in the IDE config, so none of
the models behind the providers added to 9Router showed up. Those single
models are now always added; 9Router's combos stay hidden. The copy of the
same model that comes through OmniRoute is still added only when the IDE
config names it, so each model is listed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 19:26:00 +03:00
yilsemandClaude Opus 5.5 1e0f2f9aea feat(codex-bridge): hide 9Router combos in Codex, drop the config backup (beta.22)
Combos that come from 9Router, directly or through the gateway's 9router
provider, are no longer added to Codex's model list; the gateway's own combos
keep their plain names. The IDE no longer leaves a config.toml backup behind:
turning the setting off already restores the file byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 17:51:10 +03:00
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
83 changed files with 13229 additions and 205 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 = "4"
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,176 @@
"""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 + 9Router'a eklenmis saglayicilarin modelleri +
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
DOKUZ_ISARET = "9R · "
def _isaret(ad):
"""Gecidin dogrudan 9Router'dan gelen tekil modele koydugu kisa isaret korunur (kullanici karari,
9 Ekim 2026: "9Router" yazisi uzun, hic yazmamak da sorun; "9R" olarak kisa gorunsun)."""
return DOKUZ_ISARET if (ad or "").startswith(DOKUZ_ISARET) else ""
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."""
on = _isaret(ad)
ad = (ad or "").split(" · ")[-1].strip()
parcalar = [p for p in ad.split("/") if p]
if len(parcalar) <= 2:
return on + ("/".join(parcalar) or ad)
return on + 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: _isaret(a) + (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
# 9Router'dan gelen combolar Codex'te gosterilmez (kullanici karari, 9 Ekim 2026): dogrudan
# 9Router kaynagi ve gecidin "9router" saglayicisi uzerinden gelen kopyalari.
if combo and (ts.get("source") == "9router" or mid.lower().startswith("9router-")):
continue
# 9Router'a eklenmis saglayicilarin tekil modelleri her zaman eklenir (kullanici karari, 9 Ekim 2026:
# "9Router'in combosu olmasin dedim, 9Router'in modeli olsun"). Ayni modelin OmniRoute uzerinden
# gelen kopyasi yalniz IDE ayarinda tanimliysa eklenir; boylece her model listede bir kez durur.
dokuz_model = not combo and ts.get("source") == "9router"
if kapsam != "hepsi" and not combo and not dokuz_model 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"]))
# Gecit ayni adin ikinci yoluna "ad (2)" der; birincisi listede yoksa sayac anlamsizdir.
adlar = {s["ad"].split(" · ")[-1].strip().lower() for s in secilen}
for s in secilen:
taban = re.sub(r" \(\d+\)$", "", s["ad"])
if taban != s["ad"] and taban.split(" · ")[-1].strip().lower() not in adlar:
adlar.add(taban.split(" · ")[-1].strip().lower())
s["ad"] = taban
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,108 @@
import json, os, tempfile, unittest
import ide_catalog as c
def girdi(mid, ad, yetenek=("chat",), sira=0, kaynak="omniroute"):
return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira, "source": kaynak}}
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"])
def test_9router_combolari_gosterilmez(self):
katalog = [girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=1),
girdi("9router-Takim-Agir", "Takim-Agir (2)", sira=2),
girdi("Takim-Agir", "Takim-Agir (3)", sira=3),
girdi("Ajan-Arac", "Ajan-Arac", sira=4),
girdi("9router-nano-x", "nano/x", kaynak="9router", sira=5)]
s = c.sec(katalog, ["nano/x"], "ide")
# 9Router combosu ve gecidin 9router saglayicisindaki kopyasi yok; OmniRoute'un kendi combosu
# sayacsiz adiyla kalir. 9Router'in tekil modeli (combo degil) etkilenmez.
self.assertEqual([(x["id"], x["ad"]) for x in s],
[("Takim-Agir", "Takim-Agir"), ("Ajan-Arac", "Ajan-Arac"), ("9router-nano-x", "nano/x")])
def test_9router_saglayici_modelleri_tanimsiz_da_olsa_eklenir(self):
katalog = [girdi("9router-nano-x-aaaa1111", "nano/x", kaynak="9router", sira=1),
girdi("9router-nano-x", "nano/x (2)", sira=2),
girdi("9router-ocg-y-bbbb2222", "ocg/y", kaynak="9router", sira=3),
girdi("vag-z", "vag/z", sira=4),
girdi("9router-ses", "nano/ses", ("tts",), kaynak="9router", sira=5),
girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=6)]
s = c.sec(katalog, [], "ide")
# 9Router modelleri IDE ayarinda adi gecmese de gelir; OmniRoute kopyasi, OmniRoute'un kendi
# saglayici modeli, sohbet olmayan model ve 9Router combosu gelmez.
self.assertEqual([(x["id"], x["ad"]) for x in s],
[("9router-nano-x-aaaa1111", "nano/x"), ("9router-ocg-y-bbbb2222", "ocg/y")])
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"),
("9R · nano/qwen/qwen3.8-27b", "9R · nano/qwen3.8-27b"), ("9R · ag/gemini", "9R · ag/gemini"),
("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), 3) # iki 9Router combosu hicbir kapsamda gosterilmez
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,32 @@
# Mobile connection repair — beta.19
The paired phone previously resolved the desktop's active profile. If that
profile's startup promise was waiting for first-run setup, every phone API
request waited too, even while the default backend was already serving.
REST and WebSocket requests now resolve the phone's explicit `profile` query
(default when absent). Aggregate `profile=all` reaches the default process
without rewriting the downstream query. Invalid profile identifiers, remote
backends and a missing local runtime are refused; phone requests do not install
software or silently fall back to a different profile. Resolution has an eight
second deadline; this is not an agent, tool or scheduled-job execution deadline.
Shared-runtime completion wakes only a waiting setup for the same runtime root.
Primary startup then resolves its own profile again. This does not grant
installation consent or substitute a sibling profile's backend connection.
## Evidence
- 94 mobile transport, certificate, relay, profile and startup regression tests.
- Electron TypeScript type check.
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
backend behind the production TLS proxy, pinned certificate, screen reads,
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
job is deleted; existing jobs are untouched. This test does not trigger an
agent job or claim physical-phone/cellular acceptance.
- Public relay hello was separately exercised using Android's production
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
Publication, installed beta.19 and physical-phone acceptance are separate gates.
Existing phone security exclusions are unchanged. Unsupported methods and
features are not claimed to be implemented by this connection repair.
@@ -0,0 +1,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()
})
})
+429
View File
@@ -0,0 +1,429 @@
/**
* 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 = '4'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
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
}
+400
View File
@@ -0,0 +1,400 @@
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 STATUS_OK = { ok: true, surum: '4', 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)
},
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 → login item
expect(world.events).toEqual([
`write ${BRIDGE_JSON}`,
`spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`,
'status ok',
`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.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.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('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([])
})
})
+527
View File
@@ -0,0 +1,527 @@
/**
* 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_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
/** 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.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,
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
}
+112 -21
View File
@@ -36,6 +36,8 @@ 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,
@@ -2075,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) => {
@@ -5259,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
}
@@ -6795,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) {
@@ -13081,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',
@@ -17172,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
@@ -17646,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())
@@ -17853,19 +17936,13 @@ function getMobileLink(): MobileLink {
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
vault: new MobileVault(directory, safeStorage),
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
resolveBackend: async () => {
if (mobileLinkRemoteMode()) {
throw new Error('aiturk-mobile:remote_mode')
}
const connection = await ensureBackend(primaryProfileKey())
if (connection.mode !== 'local') {
throw new Error('aiturk-mobile:remote_mode')
}
return { baseUrl: connection.baseUrl, token: connection.token }
},
resolveBackend: createMobileBackendResolver({
isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
runtimeNeedsInstall: profile =>
resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
'bootstrap-needed',
ensureBackend
}),
isRemoteMode: mobileLinkRemoteMode,
firewall: createWindowsFirewall({
spawn,
@@ -17875,11 +17952,20 @@ function getMobileLink(): MobileLink {
// console code page; latin1 keeps every byte and the parser needs no translated word.
run: (file, args) =>
new Promise(resolve => {
execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => {
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0
execFile(
file,
args,
{ windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 },
(error, stdout) => {
const code = error
? typeof (error as { code?: unknown }).code === 'number'
? ((error as { code?: unknown }).code as number)
: null
: 0
resolve({ code, stdout: String(stdout ?? '') })
})
resolve({ code, stdout: String(stdout ?? '') })
}
)
})
}),
fetch: (url, init) => fetch(url, init as RequestInit),
@@ -17939,7 +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
@@ -0,0 +1,48 @@
import { expect, it, vi } from 'vitest'
import { createMobileBackendResolver } from './mobile-backend'
const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' }
it('uses the phone profile even while another desktop profile is awaiting setup', async () => {
const ensureBackend = vi.fn(async (profile: string) => {
if (profile === 'fusion-frontier') return new Promise<typeof local>(() => {})
return local
})
const resolve = createMobileBackendResolver({
isRemote: () => false,
runtimeNeedsInstall: () => false,
ensureBackend
})
expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token })
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default')
})
it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => {
const ensureBackend = vi.fn(async () => local)
const resolve = createMobileBackendResolver({
isRemote: profile => profile === 'remote',
runtimeNeedsInstall: () => true,
ensureBackend
})
await expect(resolve('default')).rejects.toThrow('runtime_not_ready')
await expect(resolve('remote')).rejects.toThrow('remote_mode')
expect(ensureBackend).not.toHaveBeenCalled()
})
it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => {
let remote = false
const ensureBackend = vi.fn(async () => local)
const resolve = createMobileBackendResolver({
isRemote: () => remote,
runtimeNeedsInstall: () => false,
ensureBackend
})
await resolve('kodlama')
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama')
ensureBackend.mockImplementationOnce(async () => {
remote = true
return local
})
await expect(resolve('kodlama')).rejects.toThrow('remote_mode')
})
+18
View File
@@ -0,0 +1,18 @@
/** Mobile routing is explicit and cannot start a first-run installer. */
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
isRemote,
runtimeNeedsInstall,
ensureBackend
}: {
isRemote: (profile: string) => boolean
runtimeNeedsInstall: (profile: string) => boolean
ensureBackend: (profile: string) => Promise<Connection>
}) {
return async (profile: string) => {
if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready')
const connection = await ensureBackend(profile)
if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
return { baseUrl: connection.baseUrl, token: connection.token }
}
}
+401 -112
View File
@@ -46,7 +46,10 @@ import {
const SESSION = crypto.randomBytes(32).toString('base64url')
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1'])
const hostOnly = (value: unknown) => String(value || '').replace(/:\d+$/, '').toLowerCase()
const hostOnly = (value: unknown) =>
String(value || '')
.replace(/:\d+$/, '')
.toLowerCase()
const seen: { path: string; host: string; raw: string; method: string }[] = []
const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ')
@@ -90,7 +93,12 @@ beforeAll(async () => {
}
}
seen.push({ path: req.url ?? '', host: String(req.headers.host), method: String(req.method), raw: JSON.stringify(req.headers) + req.url })
seen.push({
path: req.url ?? '',
host: String(req.headers.host),
method: String(req.method),
raw: JSON.stringify(req.headers) + req.url
})
send(200, JSON.stringify({ ok: true, path: url.pathname }))
})
@@ -143,48 +151,50 @@ interface CallOptions {
}
function call(port: number, method: string, path: string, options: CallOptions = {}) {
return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>((resolve, reject) => {
const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body))
return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>(
(resolve, reject) => {
const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body))
const req = https.request(
{
host: '127.0.0.1',
port,
method,
path,
agent: false,
...(options.ca ? { ca: options.ca } : {}),
checkServerIdentity: () => undefined,
headers: {
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}),
...(payload ? { 'content-type': 'application/json' } : {}),
origin: 'http://evil.example',
cookie: 'session=evil',
...options.headers
}
},
res => {
let text = ''
res.on('data', chunk => (text += chunk))
res.on('end', () => {
let json: any = null
try {
json = JSON.parse(text)
} catch {
json = null
const req = https.request(
{
host: '127.0.0.1',
port,
method,
path,
agent: false,
...(options.ca ? { ca: options.ca } : {}),
checkServerIdentity: () => undefined,
headers: {
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}),
...(payload ? { 'content-type': 'application/json' } : {}),
origin: 'http://evil.example',
cookie: 'session=evil',
...options.headers
}
},
res => {
let text = ''
resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json })
})
}
)
res.on('data', chunk => (text += chunk))
res.on('end', () => {
let json: any = null
req.on('error', reject)
req.end(payload)
})
try {
json = JSON.parse(text)
} catch {
json = null
}
resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json })
})
}
)
req.on('error', reject)
req.end(payload)
}
)
}
function wsOpen(url: string, ca: string, extra: Record<string, unknown> = {}) {
@@ -277,7 +287,10 @@ describe('LAN proxy: pairing (hello / claim)', () => {
it('claim is 410 pairing_closed while pairing is not open', async () => {
const t = await startProxy()
const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: 'AAAAAAAA', device_name: 'x' } })
const r = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: 'AAAAAAAA', device_name: 'x' }
})
expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }])
await t.proxy.stop()
@@ -288,13 +301,19 @@ describe('LAN proxy: pairing (hello / claim)', () => {
const { code } = t.proxy.beginPairing()
for (let i = 0; i < 4; i++) {
const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })
const r = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: wrongCode(code), device_name: 'x' }
})
expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }])
}
const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} `
const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: typed, device_name: ' Pixel\u0000 <b>9</b> ' } })
const ok = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: typed, device_name: ' Pixel\u0000 <b>9</b> ' }
})
expect(ok.status).toBe(200)
expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version'])
@@ -326,10 +345,20 @@ describe('LAN proxy: pairing (hello / claim)', () => {
const { code } = t.proxy.beginPairing()
for (let i = 0; i < 4; i++) {
expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })).status).toBe(401)
expect(
(
await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: wrongCode(code), device_name: 'x' }
})
).status
).toBe(401)
}
const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })
const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
body: { code: wrongCode(code), device_name: 'x' }
})
expect([fifth.status, fifth.json]).toEqual([410, { error: 'pairing_locked' }])
@@ -358,16 +387,28 @@ describe('LAN proxy: pairing (hello / claim)', () => {
const t = await startProxy()
const { code } = t.proxy.beginPairing()
for (const rawBody of ['{not json', '[]', '{"code":12345678,"device_name":"x"}', `{"code":"${code}"}`, `{"code":"${code}","device_name":""}`, JSON.stringify({ code, device_name: 'n'.repeat(61) })]) {
for (const rawBody of [
'{not json',
'[]',
'{"code":12345678,"device_name":"x"}',
`{"code":"${code}"}`,
`{"code":"${code}","device_name":""}`,
JSON.stringify({ code, device_name: 'n'.repeat(61) })
]) {
const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody })
expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }])
}
const big = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) })
const big = await call(t.port, 'POST', '/aiturk/pair/claim', {
ca: t.ca,
rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) })
})
expect(big.status).toBe(413)
expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status).toBe(200)
expect(
(await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status
).toBe(200)
await t.proxy.stop()
})
})
@@ -421,7 +462,11 @@ describe('LAN proxy: REST', () => {
for (const options of [{ token }, { headerToken: token }]) {
seen.length = 0
const r = await call(t.port, 'GET', '/api/sessions?limit=20', { ca: t.ca, ...options, headers: { 'x-forwarded-for': '10.0.0.9' } })
const r = await call(t.port, 'GET', '/api/sessions?limit=20', {
ca: t.ca,
...options,
headers: { 'x-forwarded-for': '10.0.0.9' }
})
expect(r.status).toBe(200)
expect(r.json).toEqual({ ok: true, path: '/api/sessions' })
@@ -498,7 +543,11 @@ describe('LAN proxy: REST', () => {
resolveCalls = 0
for (const [method, path] of forbidden) {
const r = await call(t.port, method, path, { ca: t.ca, token, ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) })
const r = await call(t.port, method, path, {
ca: t.ca,
token,
...(method === 'GET' || method === 'DELETE' ? {} : { body: {} })
})
expect([method, path, r.status, r.json]).toEqual([method, path, 403, { error: 'forbidden' }])
}
@@ -527,6 +576,50 @@ describe('LAN proxy: REST', () => {
await t.proxy.stop()
})
it('routes REST and WebSocket by the phone profile, never the desktop selection', async () => {
const profiles: string[] = []
const t = await startProxy({
resolveBackend: async (profile: string) => {
profiles.push(profile)
return resolveBackend()
}
})
try {
const { token } = await t.pair()
for (const query of ['', '?profile=kodlama', '?profile=all']) {
expect((await call(t.port, 'GET', `/api/cron/jobs${query}`, { ca: t.ca, token })).status).toBe(200)
}
const opened = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}&profile=seo`, t.ca)
expect(opened.open).toBe(true)
opened.ws?.close()
expect(profiles).toEqual(['default', 'kodlama', 'default', 'seo'])
expect(seen.some(row => row.path === '/api/cron/jobs?profile=all')).toBe(true)
} finally {
await t.proxy.stop()
}
})
it('bounds a pending setup/backend resolution for REST and WebSocket and never forwards the late result', async () => {
let finish: (value: { baseUrl: string; token: string }) => void = () => {}
const pending = new Promise<{ baseUrl: string; token: string }>(resolve => {
finish = resolve
})
const t = await startProxy({ resolveBackend: () => pending, backendResolveTimeoutMs: 40 })
try {
const { token } = await t.pair()
const before = seen.length
const rest = await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token })
expect([rest.status, rest.json]).toEqual([503, { error: 'backend_unavailable' }])
expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca)).status).toBe(503)
finish({ baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION })
await new Promise(resolve => setTimeout(resolve, 30))
expect(seen.length).toBe(before)
expect((await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token })).status).toBe(200)
} finally {
await t.proxy.stop()
}
})
it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => {
const remote = await startProxy({
resolveBackend: async () => {
@@ -541,7 +634,9 @@ describe('LAN proxy: REST', () => {
expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503)
await remote.proxy.stop()
const elsewhere = await startProxy({ resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) })
const elsewhere = await startProxy({
resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION })
})
const b = await elsewhere.pair()
expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503)
@@ -628,10 +723,18 @@ describe('LAN proxy: TLS, listening and logs', () => {
const t = await startProxy()
const peer = await new Promise<string>((resolve, reject) => {
const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, res => {
resolve(crypto.createHash('sha256').update((res.socket as any).getPeerCertificate(true).raw).digest('base64url'))
res.resume()
})
const req = https.request(
{ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false },
res => {
resolve(
crypto
.createHash('sha256')
.update((res.socket as any).getPeerCertificate(true).raw)
.digest('base64url')
)
res.resume()
}
)
req.on('error', reject)
req.end()
@@ -641,10 +744,14 @@ describe('LAN proxy: TLS, listening and logs', () => {
expect(peer).not.toBe(createSelfSignedCertificate().fingerprint)
// A phone pinned to ANOTHER certificate must refuse this server.
await expect(call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert })).rejects.toThrow()
await expect(
call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert })
).rejects.toThrow()
const systemCa = await new Promise<string>(resolve => {
const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => resolve('accepted'))
const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () =>
resolve('accepted')
)
req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code)))
req.end()
@@ -653,7 +760,9 @@ describe('LAN proxy: TLS, listening and logs', () => {
expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/)
const plain = await new Promise<string>(resolve => {
const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => resolve(String(res.statusCode)))
const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res =>
resolve(String(res.statusCode))
)
req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error')))
req.end()
@@ -710,7 +819,11 @@ describe('LAN proxy: TLS, listening and logs', () => {
// Stand-in for the LAN bind (production: 0.0.0.0). Still loopback, so no test socket leaves this machine,
// but it is a DIFFERENT address from 127.0.0.1, which is what "listening on the LAN or not" needs.
const LAN_HOST = '127.0.0.2'
const RELAY_IDS = ['3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f']
const RELAY_IDS = [
'3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55',
'9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77',
'5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f'
]
const HOST_TOKENS = RELAY_IDS.map((_id, index) => `tsmr_${String.fromCharCode(65 + index).repeat(43)}`)
/** Host socket that opens at once and never carries traffic; enough for the controller tests. */
@@ -835,7 +948,11 @@ function linkHarness(over: Record<string, any> = {}) {
if (isOffer) {
return {
status: offerOutcome,
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
json: async () => ({
offerId: '0f8fad5b-d9cb-469f-a165-70867728950e',
cancelToken: `tsmp_${'c'.repeat(43)}`,
expiresIn: 300
})
}
}
@@ -886,14 +1003,35 @@ function linkHarness(over: Record<string, any> = {}) {
const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query')
const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers'))
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries }
return {
link: make(),
make,
vault,
store,
firewallCalls,
firewallWrites,
fetchCalls,
offers,
events,
logs,
state,
hostSockets,
retrySleeps,
heldRetries
}
}
describe('mobile link controller', () => {
it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => {
const h = linkHarness()
expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, port: null, pairing: null, devices: [] })
expect(await h.link.state()).toMatchObject({
enabled: false,
listening: false,
port: null,
pairing: null,
devices: []
})
const enabled = await h.link.enable()
const port = enabled.port as number
@@ -953,7 +1091,10 @@ describe('mobile link controller', () => {
expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code)
const ca = h.vault.value.lanTls.cert
const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: pairing.code, device_name: 'Pixel 9' } })
const claim = await call(port, 'POST', '/aiturk/pair/claim', {
ca,
body: { code: pairing.code, device_name: 'Pixel 9' }
})
expect(claim.status).toBe(200)
await until(() => h.events.some(event => event.type === 'paired'))
@@ -1033,7 +1174,10 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin()
const port = state.port as number
const ca = h.vault.value.lanTls.cert
const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } })
const claim = await call(port, 'POST', '/aiturk/pair/claim', {
ca,
body: { code: state.pairing!.code, device_name: 'Pixel' }
})
await until(() => h.events.some(event => event.type === 'paired'))
@@ -1083,10 +1227,19 @@ describe('mobile link controller', () => {
})
it('"İzin ver" is the only thing that adds the firewall rule; a refused UAC prompt leaves direct access off', async () => {
const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] })
const h = linkHarness({
firewallResults: [
{ ok: false, reason: 'denied' },
{ ok: false, reason: 'failed' }
]
})
const state = await h.link.enable()
expect(state).toMatchObject({ enabled: true, firewall: 'needed', lan: { direct: false, listening: false, firewall: 'missing' } })
expect(state).toMatchObject({
enabled: true,
firewall: 'needed',
lan: { direct: false, listening: false, firewall: 'missing' }
})
expect(h.firewallWrites()).toEqual([])
expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } })
expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } })
@@ -1095,7 +1248,10 @@ describe('mobile link controller', () => {
// The rule is there (asked from Windows, not remembered): no second prompt.
expect((await h.link.lanAllow()).firewall).toBe('ok')
expect(h.firewallWrites()).toHaveLength(3)
expect(await h.link.firewallRemove()).toMatchObject({ firewall: 'needed', lan: { direct: false, firewall: 'missing' } })
expect(await h.link.firewallRemove()).toMatchObject({
firewall: 'needed',
lan: { direct: false, firewall: 'missing' }
})
expect(h.firewallWrites().at(-1)).toBe('remove')
await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled')
await h.link.stop()
@@ -1113,7 +1269,10 @@ describe('mobile link controller', () => {
const port = first.port as number
// Listening, but only on loopback: the stand-in LAN address refuses the connection.
expect(first).toMatchObject({ listening: true, lan: { direct: false, listening: false, port, firewall: 'missing' } })
expect(first).toMatchObject({
listening: true,
lan: { direct: false, listening: false, port, firewall: 'missing' }
})
expect(await connectOutcome(port, '127.0.0.1')).toBe('open')
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
expect(first.pairing!.qr).not.toContain('u=')
@@ -1124,7 +1283,11 @@ describe('mobile link controller', () => {
const allowed = await h.link.lanAllow()
expect(h.firewallWrites()).toEqual([`add:${port}`])
expect(allowed).toMatchObject({ pairing: null, listening: false, lan: { direct: true, listening: false, firewall: 'allowed' } })
expect(allowed).toMatchObject({
pairing: null,
listening: false,
lan: { direct: true, listening: false, firewall: 'allowed' }
})
expect(h.store.value.lanDirect).toBe(true)
const second = await h.link.pairBegin()
@@ -1168,7 +1331,14 @@ describe('mobile link controller', () => {
const h = linkHarness({
rule: { state: 'present', ports: [47321] },
stored: { v: 1, enabled: true, port: null, firewallPort: 47321, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] }
stored: {
v: 1,
enabled: true,
port: null,
firewallPort: 47321,
fingerprint: null,
devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }]
}
})
const resumed = await h.link.start()
@@ -1189,7 +1359,11 @@ describe('mobile link controller', () => {
await h.link.enable()
await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/)
expect(await h.link.state()).toMatchObject({ pairing: null, listening: false, relay: { ready: false, status: 'off' } })
expect(await h.link.state()).toMatchObject({
pairing: null,
listening: false,
relay: { ready: false, status: 'off' }
})
expect(h.offers()).toEqual([])
// Direct access on, but no usable LAN address either.
@@ -1201,7 +1375,9 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin()
expect(state.pairing!.qr).toMatch(new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`))
expect(state.pairing!.qr).toMatch(
new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`)
)
await h.link.stop()
const broken = createMobileLink({
@@ -1252,7 +1428,10 @@ describe('mobile link controller', () => {
expect(h.vault.value.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] })
expect(h.vault.value.accountToken).toBe('eski-alan')
const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } })
const claim = await call(port, 'POST', '/aiturk/pair/claim', {
ca,
body: { code: state.pairing!.code, device_name: 'Pixel' }
})
expect(claim.status).toBe(200)
await until(() => h.events.some(event => event.type === 'paired'))
@@ -1263,10 +1442,16 @@ describe('mobile link controller', () => {
// "Röle kimliğini yenile".
const renewed = await h.link.relayRenew()
expect(renewed).toMatchObject({ devices: [], listening: false, relay: { status: 'off', ready: true, reissued: true } })
expect(renewed).toMatchObject({
devices: [],
listening: false,
relay: { status: 'off', ready: true, reissued: true }
})
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[1])
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status).not.toBe(200)
expect(
(await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status
).not.toBe(200)
await until(() => h.hostSockets[0].closed !== null)
const again = await h.link.pairBegin()
@@ -1275,7 +1460,9 @@ describe('mobile link controller', () => {
await until(() => h.events.at(-1)?.state.relay.status === 'connected')
// Every socket after the renewal carries the new token; the old one is never sent again.
expect(h.hostSockets.length).toBeGreaterThan(1)
expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe(true)
expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe(
true
)
expect(h.hostSockets.at(-1)!.url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[1]}`)
// Renewal that cannot reach the gateway changes nothing.
@@ -1284,11 +1471,22 @@ describe('mobile link controller', () => {
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
// The host token never reaches a log line, an event, a state answer or the plain store.
const exposed = [h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value)].join('\n')
const exposed = [
h.logs.join('\n'),
JSON.stringify(h.events),
JSON.stringify(await h.link.state()),
JSON.stringify(h.store.value)
].join('\n')
expect(exposed).not.toContain('tsmr_')
expect(h.logs.filter(line => line.startsWith('relay '))).toEqual(expect.arrayContaining(['relay connecting', 'relay connected', 'relay off']))
expect(h.logs.filter(line => line.startsWith('relay ')).every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true)
expect(h.logs.filter(line => line.startsWith('relay '))).toEqual(
expect.arrayContaining(['relay connecting', 'relay connected', 'relay off'])
)
expect(
h.logs
.filter(line => line.startsWith('relay '))
.every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))
).toBe(true)
await h.link.stop()
})
@@ -1299,7 +1497,10 @@ describe('mobile link controller', () => {
const state = await h.link.pairBegin()
await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } })
await call(state.port as number, 'POST', '/aiturk/pair/claim', {
ca: h.vault.value.lanTls.cert,
body: { code: state.pairing!.code, device_name: 'Pixel' }
})
await until(() => h.events.some(event => event.type === 'paired'))
await h.link.stop()
@@ -1453,7 +1654,13 @@ describe('mobile link controller: rendezvous record retries', () => {
const again = (await h.link.pairRepublish()).pairing!
expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' })
expect(again).toMatchObject({
code: begun.code,
qr: begun.qr,
expiresAt: begun.expiresAt,
securityCode: begun.securityCode,
codePath: 'pending'
})
await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready')
expect(h.offers()).toHaveLength(2)
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
@@ -1501,7 +1708,13 @@ describe('mobile link controller: rendezvous record retries', () => {
})
describe('publishPairOffer / cancelPairOffer', () => {
const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] }
const input = {
code: 'ABCDEFGH',
addresses: [] as string[],
port: 47321,
fingerprint: 'f'.repeat(43),
relay: RELAY_IDS[0]
}
const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }
function plan(outcomes: (number | 'throw')[]) {
@@ -1577,7 +1790,10 @@ describe('publishPairOffer / cancelPairOffer', () => {
const told = plan([409])
expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null })
expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({
offerId: null,
cancelToken: null
})
})
it('stops as soon as the pairing is over, without a log line', async () => {
@@ -1624,12 +1840,18 @@ describe('pure helpers', () => {
expect(normalizePairCode(' abcd-efgh ')).toBe('ABCDEFGH')
expect(normalizePairCode(12345678)).toBe('')
expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH')
expect(pairLookup('abcd-efgh')).toBe(crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex'))
expect(pairLookup('abcd-efgh')).toBe(
crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex')
)
})
it('address candidates: private IPv4 only, default route first, virtual adapters dropped, at most four', () => {
expect(['10.1.2.3', '172.16.0.1', '172.31.255.254', '192.168.0.1'].every(isPrivateIpv4)).toBe(true)
expect(['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some(isPrivateIpv4)).toBe(false)
expect(
['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some(
isPrivateIpv4
)
).toBe(false)
const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }]
@@ -1646,13 +1868,25 @@ describe('pure helpers', () => {
'Ethernet 4': v4('192.168.4.2')
}
expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual(['192.168.1.20', '10.0.0.5', '192.168.2.2', '192.168.3.2'])
expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual([
'192.168.1.20',
'10.0.0.5',
'192.168.2.2',
'192.168.3.2'
])
expect(selectLanAddresses(interfaces, null)[0]).toBe('10.0.0.5')
expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([])
expect(() => buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toThrow(
'aiturk-mobile:no_lan_address'
)
expect(buildPairQrPayload({ addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toBe(
expect(() =>
buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })
).toThrow('aiturk-mobile:no_lan_address')
expect(
buildPairQrPayload({
addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'],
port: 47321,
fingerprint: 'f'.repeat(43),
code: 'ABCDEFGH'
})
).toBe(
`aiturk://pair?v=1&u=10.0.0.1:47321&u=10.0.0.2:47321&u=10.0.0.3:47321&u=10.0.0.4:47321&fp=${'f'.repeat(43)}&c=ABCDEFGH`
)
})
@@ -1683,7 +1917,11 @@ describe('pure helpers', () => {
expect(() => firewallCommand('add', bad)).toThrow('aiturk-mobile:invalid_port')
}
for (const [exit, expected] of [[0, { ok: true }], [77, { ok: false, reason: 'denied' }], [1, { ok: false, reason: 'failed' }]] as const) {
for (const [exit, expected] of [
[0, { ok: true }],
[77, { ok: false, reason: 'denied' }],
[1, { ok: false, reason: 'failed' }]
] as const) {
const spawned: any[] = []
const firewall = createWindowsFirewall({
@@ -1725,7 +1963,11 @@ describe('pure helpers', () => {
parseMobileLinkStored({
enabled: 'yes',
port: 99999,
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null]
devices: [
{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' },
{ id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 },
null
]
})
).toEqual({
enabled: false,
@@ -1748,8 +1990,12 @@ describe('pure helpers', () => {
relayReissued: false
})
// Only an explicit `true` turns direct LAN access on; a relay id must be a uuid.
expect(parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 })).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false })
expect(parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })).toMatchObject({
expect(
parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 })
).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false })
expect(
parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })
).toMatchObject({
lanDirect: true,
relayId: RELAY_IDS[0],
relayReissued: true
@@ -1760,16 +2006,30 @@ describe('pure helpers', () => {
const fp = 'f'.repeat(43)
const relay = RELAY_IDS[0]
expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(`aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`)
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(
`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`
expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(
`aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`
)
expect(
buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })
).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`)
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe(
`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH`
)
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH`)
// Anything that is not exactly a uuid is dropped, never echoed into the QR.
for (const bad of [`https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, relay.toUpperCase(), `${relay}&u=1.2.3.4:1`, '', 7 as any]) {
expect(buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).not.toContain('r=')
expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).toThrow('aiturk-mobile:no_lan_address')
for (const bad of [
`https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`,
relay.toUpperCase(),
`${relay}&u=1.2.3.4:1`,
'',
7 as any
]) {
expect(
buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })
).not.toContain('r=')
expect(() =>
buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })
).toThrow('aiturk-mobile:no_lan_address')
}
const bodies: any[] = []
@@ -1777,13 +2037,22 @@ describe('pure helpers', () => {
const fetcher = async (_url: string, init: any) => {
bodies.push(JSON.parse(init.body))
return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) }
return {
status: 201,
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` })
}
}
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay })).not.toBeNull()
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })).not.toBeNull()
expect(
await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay })
).not.toBeNull()
expect(
await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })
).not.toBeNull()
// Neither a LAN url nor a relay: nothing is published.
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' })).toBeNull()
expect(
await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' })
).toBeNull()
expect(bodies).toEqual([
{ lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay },
{ lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp }
@@ -1793,7 +2062,10 @@ describe('pure helpers', () => {
it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => {
const query = firewallQueryCommand('C:\\Windows')
expect(query).toEqual({ file: 'C:\\Windows\\System32\\netsh.exe', args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] })
expect(query).toEqual({
file: 'C:\\Windows\\System32\\netsh.exe',
args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi']
})
expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe')
const english = (port: string, enabled = 'Yes') =>
@@ -1840,14 +2112,24 @@ describe('pure helpers', () => {
expect(parseFirewallRuleOutput(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
expect(parseFirewallRuleOutput(turkish('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
expect(parseFirewallRuleOutput(english('47330'), 0)).toEqual({ state: 'present', ports: [47330] })
expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ state: 'present', ports: [47321, 47325, 47326] })
expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({
state: 'present',
ports: [47321, 47325, 47326]
})
// Two rules under the same name (the port changed once): both ports count.
expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ state: 'present', ports: [47321, 47322] })
expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({
state: 'present',
ports: [47321, 47322]
})
// A disabled rule allows nothing.
expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] })
expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 0)).toEqual({ state: 'present', ports: [] })
expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ state: 'absent' })
expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' })
expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({
state: 'absent'
})
expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({
state: 'absent'
})
// No answer at all (netsh missing, timeout) is not "no rule".
expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' })
expect(parseFirewallRuleOutput('', 1)).toEqual({ state: 'unknown' })
@@ -1871,9 +2153,16 @@ describe('pure helpers', () => {
expect(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] })
expect(ran).toEqual([query])
const failing = createWindowsFirewall({ platform: 'win32', env: {}, spawn: () => ({ on: () => undefined }), run: async () => Promise.reject(new Error('x')) })
const failing = createWindowsFirewall({
platform: 'win32',
env: {},
spawn: () => ({ on: () => undefined }),
run: async () => Promise.reject(new Error('x'))
})
expect(await failing.queryRule!()).toEqual({ state: 'unknown' })
expect(await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!()).toEqual({ state: 'unknown' })
expect(
await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!()
).toEqual({ state: 'unknown' })
})
})
+167 -39
View File
@@ -150,7 +150,11 @@ export function selectLanAddresses(
const ipv4 = item.family === 'IPv4' || item.family === 4
if (ipv4 && !item.internal && isPrivateIpv4(item.address) && !rows.some(row => row.address === item.address)) {
rows.push({ address: item.address, primary: item.address === defaultRoute, virtual: VIRTUAL_ADAPTER_RE.test(name) })
rows.push({
address: item.address,
primary: item.address === defaultRoute,
virtual: VIRTUAL_ADAPTER_RE.test(name)
})
}
}
}
@@ -216,7 +220,13 @@ export function defaultRouteAddress(): Promise<null | string> {
* registration exists. A QR with neither would be a code that cannot work, so
* that is refused.
*/
export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string; relay?: null | string }): string {
export function buildPairQrPayload(input: {
addresses: string[]
port: number
fingerprint: string
code: string
relay?: null | string
}): string {
const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS)
const relay = isRelayId(input.relay) ? input.relay : null
@@ -437,7 +447,10 @@ export interface MobileFirewall {
/** `netsh advfirewall firewall show rule name=…` — fixed arguments, no shell, no elevation. */
export function firewallQueryCommand(systemRoot?: string) {
const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows'
const root =
systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot)
? systemRoot.replace(/\\+$/, '')
: 'C:\\Windows'
return {
file: `${root}\\System32\\netsh.exe`,
@@ -479,7 +492,9 @@ export function parseFirewallRuleOutput(stdout: string, exitCode: null | number)
continue
}
const portLine = block.find(line => /^LocalPort\s*:/i.test(line)) ?? block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line)))
const portLine =
block.find(line => /^LocalPort\s*:/i.test(line)) ??
block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line)))
for (const part of value(portLine).split(',')) {
const [from, to = from] = part.split('-').map(item => Number(item.trim()))
@@ -519,7 +534,10 @@ function assertPort(port: number): number {
* shell is involved (the caller spawns PowerShell with an argument array).
*/
export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) {
const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows'
const root =
systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot)
? systemRoot.replace(/\\+$/, '')
: 'C:\\Windows'
const netshArgs =
action === 'add'
@@ -537,7 +555,11 @@ export function firewallCommand(action: 'add' | 'remove', port: number, systemRo
}
export interface FirewallSpawn {
(file: string, args: string[], options: Record<string, unknown>): {
(
file: string,
args: string[],
options: Record<string, unknown>
): {
on: (event: string, listener: (...args: any[]) => void) => unknown
}
}
@@ -686,7 +708,9 @@ export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
export interface LanProxyOptions {
tls: { key: string; cert: string }
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
resolveBackend: () => Promise<MobileBackend>
resolveBackend: (profile: string) => Promise<MobileBackend>
/** Resolution only, never the duration of an agent/tool/cron execution. */
backendResolveTimeoutMs?: number
version: string
pcName: string
devices?: MobileDeviceRecord[]
@@ -733,7 +757,13 @@ const DROPPED_REQUEST_HEADERS = new Set([
const DROPPED_RESPONSE_HEADERS = ['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'proxy-authenticate']
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
const STATUS_TEXT: Record<number, string> = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' }
const STATUS_TEXT: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
503: 'Service Unavailable'
}
export function createLanProxy(options: LanProxyOptions) {
const now = options.now ?? Date.now
@@ -752,7 +782,13 @@ export function createLanProxy(options: LanProxyOptions) {
}
const records = (): MobileDeviceRecord[] =>
[...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt }))
[...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({
id,
name,
tokenHash,
createdAt,
lastSeenAt
}))
const log = (method: string | undefined, path: string, status: number) => {
if (options.log) {
@@ -814,9 +850,21 @@ export function createLanProxy(options: LanProxyOptions) {
return device
}
async function localBackend(): Promise<null | { target: URL; token: string }> {
async function localBackend(url: URL): Promise<null | { target: URL; token: string }> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const backend = await options.resolveBackend()
// A paired phone controls this machine, but its selected profile is NOT
// the desktop window's selection. `all` is an aggregate read on the root
// dashboard, not a profile to spawn. Keep the original query downstream.
const selected = url.searchParams.get('profile')?.trim().toLowerCase() || 'default'
const profile = selected === 'all' ? 'default' : selected
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) return null
const backend = await Promise.race([
options.resolveBackend(profile),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('backend_not_ready')), options.backendResolveTimeoutMs ?? 8000)
})
])
const target = new URL(backend.baseUrl)
// Never forward a phone to anything but the loopback backend.
@@ -827,6 +875,8 @@ export function createLanProxy(options: LanProxyOptions) {
return { target, token: backend.token }
} catch {
return null
} finally {
if (timer) clearTimeout(timer)
}
}
@@ -883,7 +933,13 @@ export function createLanProxy(options: LanProxyOptions) {
}
// eslint-disable-next-line no-control-regex
const name = typeof data?.device_name === 'string' ? data.device_name.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() : ''
const name =
typeof data?.device_name === 'string'
? data.device_name
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
: ''
if (!data || typeof data !== 'object' || typeof data.code !== 'string' || name.length < 1 || name.length > 60) {
return reply(req, res, path, 400, { error: 'invalid_request' })
@@ -933,12 +989,27 @@ export function createLanProxy(options: LanProxyOptions) {
return reply(req, res, path, 500, { error: 'internal' })
}
options.onPairingEnded?.('claimed', records().find(row => row.id === device.id))
options.onPairingEnded?.(
'claimed',
records().find(row => row.id === device.id)
)
return reply(req, res, path, 200, { token, device_id: device.id, pc_name: options.pcName, version: options.version })
return reply(req, res, path, 200, {
token,
device_id: device.id,
pc_name: options.pcName,
version: options.version
})
}
function forward(req: http.IncomingMessage, res: http.ServerResponse, url: URL, path: string, device: LiveDevice, backend: { target: URL; token: string }) {
function forward(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
path: string,
device: LiveDevice,
backend: { target: URL; token: string }
) {
const headers: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(req.headers)) {
@@ -1037,7 +1108,9 @@ export function createLanProxy(options: LanProxyOptions) {
return reply(req, res, path, 403, { error: 'forbidden' })
}
const backend = await localBackend()
const backend = await localBackend(url)
if (res.destroyed) return
if (!backend) {
return reply(req, res, path, 503, { error: 'backend_unavailable' })
@@ -1086,7 +1159,9 @@ export function createLanProxy(options: LanProxyOptions) {
return refuse(path, 403, 'forbidden')
}
const backend = await localBackend()
const backend = await localBackend(url)
if (socket.destroyed) return
if (!backend) {
return refuse(path, 503, 'backend_unavailable')
@@ -1118,9 +1193,19 @@ export function createLanProxy(options: LanProxyOptions) {
socket.on('close', close)
upstream.once('connect', () => {
// No Origin / Cookie / Authorization: only what the WebSocket handshake needs.
const lines = [`GET ${path}${url.search} HTTP/1.1`, `Host: ${backend.target.host}`, 'Connection: Upgrade', 'Upgrade: websocket']
const lines = [
`GET ${path}${url.search} HTTP/1.1`,
`Host: ${backend.target.host}`,
'Connection: Upgrade',
'Upgrade: websocket'
]
for (const key of ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol']) {
for (const key of [
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
'sec-websocket-protocol'
]) {
const value = req.headers[key]
if (typeof value === 'string' && value) {
@@ -1140,9 +1225,12 @@ export function createLanProxy(options: LanProxyOptions) {
})
}
const server = https.createServer({ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' }, (req, res) => {
onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' }))
})
const server = https.createServer(
{ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' },
(req, res) => {
onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' }))
}
)
server.on('upgrade', (req, socket, head) => {
onUpgrade(req, socket, head).catch(() => socket.destroy())
@@ -1201,7 +1289,11 @@ export function createLanProxy(options: LanProxyOptions) {
beginPairing(): { code: string; expiresAt: number } {
const code = generatePairCode(randomBytes)
pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 }
pairing = {
codeHash: crypto.createHash('sha256').update(code).digest(),
expiresAt: now() + codeTtlMs,
attempts: 0
}
return { code, expiresAt: pairing.expiresAt }
},
@@ -1217,7 +1309,13 @@ export function createLanProxy(options: LanProxyOptions) {
pairingOpen,
records,
devices: (): MobileDeviceView[] =>
[...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })),
[...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({
id,
name,
createdAt,
lastSeenAt,
live: live.size
})),
/** Removes the device: its token turns 401 and its open connections are closed. */
async revoke(id: string): Promise<boolean> {
for (const [hash, device] of devices) {
@@ -1293,7 +1391,8 @@ export function createMobileLinkFileStore(directory: string, file: string, fs: M
}
}
const validPort = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536
const validPort = (value: unknown): value is number =>
Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536
export function parseMobileLinkStored(raw: unknown): MobileLinkStored {
const row = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
@@ -1407,12 +1506,13 @@ export interface MobileLinkState {
devices: MobileDeviceView[]
}
export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
export type MobileLinkEvent =
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
export interface MobileLinkDeps {
vault: MobileLanVault
store: MobileLinkStore
resolveBackend: () => Promise<MobileBackend>
resolveBackend: (profile: string) => Promise<MobileBackend>
isRemoteMode: () => boolean
firewall: MobileFirewall
fetch: PairFetch
@@ -1577,7 +1677,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
port: data.port,
remote: deps.isRemoteMode(),
firewall: firewallState(data),
relay: { status: relayState.status, code: relayState.code, ready: data.relayId !== null, reissued: data.relayReissued },
relay: {
status: relayState.status,
code: relayState.code,
ready: data.relayId !== null,
reissued: data.relayReissued
},
lan: {
direct: data.lanDirect,
listening: listening && data.lanDirect && proxy?.host === lanHost,
@@ -1586,7 +1691,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
},
pcName,
pairing: session
? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath, securityCode: session.fingerprint.slice(0, 8) }
? {
code: session.code,
qr: session.qr,
expiresAt: session.expiresAt,
codePath: session.codePath,
securityCode: session.fingerprint.slice(0, 8)
}
: null,
devices: proxy
? proxy.devices()
@@ -1635,7 +1746,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
function publishOffer(current: PairSession, relayId: null | string) {
current.codePath = 'pending'
const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId }
const input = {
code: current.code,
addresses: current.addresses,
port: current.port,
fingerprint: current.fingerprint,
relay: relayId
}
void publishPairOffer(deps.fetch, input, {
sleep,
@@ -1704,12 +1821,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
proxy = createLanProxy({
tls: identity,
resolveBackend: async () => {
resolveBackend: async profile => {
if (deps.isRemoteMode()) {
throw new MobileLinkError('remote_mode')
}
return deps.resolveBackend()
return deps.resolveBackend(profile)
},
version: deps.version,
pcName,
@@ -1785,12 +1902,17 @@ export function createMobileLink(deps: MobileLinkDeps) {
const row = (await deps.vault.load())?.relay
return isRelayCredentials(row) && row.relayId === data.relayId ? { relayId: row.relayId, hostToken: row.hostToken } : null
return isRelayCredentials(row) && row.relayId === data.relayId
? { relayId: row.relayId, hostToken: row.hostToken }
: null
},
async save(credentials, info) {
const previous = data.relayId
await deps.vault.save({ ...((await deps.vault.load()) ?? {}), relay: { relayId: credentials.relayId, hostToken: credentials.hostToken } })
await deps.vault.save({
...((await deps.vault.load()) ?? {}),
relay: { relayId: credentials.relayId, hostToken: credentials.hostToken }
})
data.relayId = credentials.relayId
// The public address the paired phones stored is gone with the old registration.
@@ -1876,7 +1998,8 @@ export function createMobileLink(deps: MobileLinkDeps) {
* is paired OR pairing is in progress). Both follow the same condition.
*/
async function syncListening(data: MobileLinkStored) {
const wanted = () => !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null)
const wanted = () =>
!stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null)
// ensureProxy may drop phones paired against a replaced certificate, so ask again after it.
const instance = wanted() ? await ensureProxy(data) : proxy
@@ -2002,7 +2125,9 @@ export function createMobileLink(deps: MobileLinkDeps) {
}
// LAN candidates only when the phone could really reach them.
const addresses = directWanted(data) ? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) : []
const addresses = directWanted(data)
? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS)
: []
if (!relayUrl && !addresses.length) {
throw new MobileLinkError('no_lan_address')
@@ -2022,9 +2147,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
codePath: 'pending',
offer: null,
// The proxy notices expiry lazily (on a request); this closes the socket on time.
timer: setTimer(() => {
instance.pairingOpen()
}, Math.max(0, expiresAt - now()) + 50),
timer: setTimer(
() => {
instance.pairingOpen()
},
Math.max(0, expiresAt - now()) + 50
),
addresses,
port,
fingerprint,
+139
View File
@@ -0,0 +1,139 @@
/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */
import https from 'node:https'
import crypto from 'node:crypto'
import type { PeerCertificate } from 'node:tls'
import { expect, it } from 'vitest'
import { WebSocket } from 'ws'
import { createSelfSignedCertificate } from './mobile-lan-cert'
import { createLanProxy } from './mobile-lan-proxy'
it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
'real backend: pinned TLS, screens, cron lifecycle and chat socket',
async () => {
const port = Number(process.env.AITURK_TEST_BACKEND_PORT)
expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true)
const baseUrl = `http://127.0.0.1:${port}`
const root = await (await fetch(baseUrl)).text()
const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1]
expect(Boolean(session)).toBe(true)
const identity = createSelfSignedCertificate()
const checkServerIdentity = (_host: string, cert: PeerCertificate) =>
crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint
? undefined
: new Error('Certificate pin mismatch')
const proxy = createLanProxy({
tls: identity,
version: 'acceptance',
pcName: 'acceptance',
resolveBackend: async profile => {
if (profile !== 'default') throw new Error('Acceptance may only use the default profile')
return { baseUrl, token: session! }
}
})
const proxyPort = await proxy.listen(0, '127.0.0.1')
let deviceToken = ''
let createdId: string | undefined
const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> =>
new Promise((resolve, reject) => {
const req = https.request(
{
hostname: '127.0.0.1',
port: proxyPort,
path,
method,
ca: identity.cert,
checkServerIdentity,
headers: {
...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}),
'Content-Type': 'application/json'
}
},
res => {
let value = ''
res.on('data', chunk => (value += chunk))
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null })
} catch {
reject(new Error(`Non-JSON response: ${path}`))
}
})
}
)
req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`)))
req.on('error', reject)
req.end(body === undefined ? undefined : JSON.stringify(body))
})
try {
const pair = await call('/aiturk/pair/claim', 'POST', {
code: proxy.beginPairing().code,
device_name: 'Acceptance ephemeral'
})
expect(pair.status).toBe(200)
deviceToken = pair.data.token
for (const endpoint of [
'health',
'profiles',
'sessions',
'cron/jobs',
'cron/blueprints',
'cron/delivery-targets',
'model/info',
'skills',
'tools/toolsets',
'mcp/servers',
'plugins/kanban/boards'
]) {
const response = await call(`/api/${endpoint}?profile=default`)
expect(response.status, endpoint).toBe(200)
}
const created = await call('/api/cron/jobs?profile=default', 'POST', {
name: `AITURK mobile acceptance ${Date.now()}`,
schedule: '0 0 1 1 *',
prompt: 'Temporary acceptance record; deleted immediately without execution.',
deliver: 'local'
})
expect(created.status).toBe(200)
createdId = created.data.id
expect(typeof createdId).toBe('string')
const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}`
expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200)
expect(
(await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status
).toBe(200)
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
ca: identity.cert,
checkServerIdentity,
headers: { Authorization: `Bearer ${deviceToken}` }
})
const timer = setTimeout(() => {
ws.terminate()
reject(new Error('Chat ping timed out'))
}, 15000)
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} })))
ws.on('message', raw => {
const message = JSON.parse(String(raw))
if (message.id === 123) {
clearTimeout(timer)
ws.close()
message.error ? reject(new Error('Chat ping rejected')) : resolve()
}
})
ws.on('error', error => {
clearTimeout(timer)
reject(error)
})
})
} finally {
if (createdId) {
const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default`
expect((await call(route, 'DELETE')).status).toBe(200)
expect((await call(route)).status).toBe(404)
}
await proxy.stop()
}
},
180000
)
+10
View File
@@ -290,6 +290,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// 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) }
}
+11 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.17",
"version": "1.0.0-beta.23",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
@@ -299,6 +299,16 @@
"*.dll",
"manifest.json"
]
},
{
"from": "assets/codex-bridge",
"to": "codex-bridge",
"filter": [
"**/*.py",
"**/*.pyd",
"**/LICENSE",
"!test_*.py"
]
}
]
},
@@ -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>
)
}
+21 -1
View File
@@ -9,6 +9,7 @@ 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,
@@ -20,6 +21,7 @@ import {
Info,
Keyboard,
KeyRound,
Link,
Package,
RefreshCw,
Search,
@@ -46,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'
@@ -68,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'notifications',
'billing',
'codex-link',
'plugins',
'sessions',
'about'
@@ -76,6 +80,7 @@ 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()
@@ -267,6 +272,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
}
]
: []),
// "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,
@@ -320,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
@@ -439,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'
+8
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'
@@ -335,6 +336,13 @@ declare global {
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,
+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
}
+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'
]
}
})
+29
View File
@@ -1470,6 +1470,34 @@ def _wait_for_oneshot_background_completions(cli) -> None:
)
def _emit_approval_required(session_id) -> None:
"""Report approvals a one-shot run could not put in front of a human.
One ``approval_required: <json>`` line per blocked request on stderr,
next to ``session_id:``, so the process that launched this run can tell
"blocked, needs a human" from "still working" without parsing prose.
Nothing was applied for these requests; resuming the same session in an
interactive surface is the way to get them approved.
"""
try:
from tools.approval import drain_human_approval_required
records = drain_human_approval_required()
except Exception:
return
for record in records:
try:
if not record.get("session_id"):
record["session_id"] = session_id or ""
record["resume"] = "interactive"
print(
"approval_required: "
+ json.dumps(record, ensure_ascii=True, sort_keys=True),
file=sys.stderr,
)
except Exception:
continue
def _finalize_single_query(cli) -> None:
"""Close one-shot CLI resources before releasing the active session lease."""
try:
@@ -22360,6 +22388,7 @@ def main(
# Session ID goes to stderr so piped stdout is clean.
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
_emit_approval_required(cli.session_id)
# Ensure proper exit code for automation wrappers.
#
+20
View File
@@ -0,0 +1,20 @@
# AITURK IDE beta.20 mobile adapter
The mobile catalog now uses the official live Hermes catalog, its cache and
removed-entry list. Parser and nine official offline seed entries are ported
from NousResearch/hermes-agent ad12263a5ed43109d3942a8af04d4cef210fa6ae.
Only PyYAML import compatibility differs in the parser. The existing installer
checks out the catalog's exact commit; client URLs cannot override catalog names.
Version/platform incompatibility is reported before installation.
Mobile subagent roster/tail RPCs are connected to the existing live registry.
Roster supports durable conversation lineage after reconnect, like upstream.
Tail and interrupt retain exact live-session/transport authority. Transcript
reads are bounded and confined to the profile's delegation log directory.
The existing mobile fork fallback already uses session.branch only for -32601;
it was not a missing feature and is unchanged.
No new model, team, cron job, permission, or account is installed by these changes.
The mobile Keys/Files/Vault/Connectors exclusions remain as before; this release
does not claim those unsupported remote screens are implemented. Android beta.5
can use the new endpoints without a new APK. Physical-phone acceptance is separate.
+4 -1
View File
@@ -769,7 +769,10 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
openai/codex#36793). ``process_group`` only changes which group the child
belongs to; it does not detach the terminal or alter the fast path.
"""
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
env = noninteractive_git_env()
# Read-only probes must not fetch missing trees in partial clones.
env["GIT_NO_LAZY_FETCH"] = "1"
result = bounded_probe_run(argv, timeout=timeout, env=env)
if result is None or result.returncode != 0:
return ""
return (result.stdout or "").strip()
+54
View File
@@ -0,0 +1,54 @@
"""AITURK adapter for the upstream catalog and existing pinned installer.
Catalog parsing/cache comes from NousResearch/hermes-agent ad12263a5ed4.
The catalog supplies the repository and immutable revision, never the client.
"""
from dataclasses import asdict
import sys
from hermes_cli import plugin_catalog as catalog
def catalog_payload():
from hermes_cli.plugins_cmd import _discover_all_plugins, _read_install_metadata
installed = {row[0] for row in _discover_all_plugins()}
metadata = _read_install_metadata()
removed = catalog.resolved_removed_entries()
entries = []
for entry in catalog.load_catalog_live():
if catalog.match_removed(entry.name, removed) or catalog.match_removed(entry.repo, removed):
continue
row = entry.to_dict()
record = metadata.get(entry.name, {})
revision = record.get("revision")
row.update(installed=entry.name in installed, installed_sha=revision,
update_available=bool(revision and revision != entry.sha),
sha_short=entry.sha[:12], capability_summary=catalog.entry_capability_summary(entry))
entries.append(row)
return {"entries": entries, "removed": [asdict(row) for row in removed]}
def install_catalog_plugin(name, *, force=False, enable=True):
from hermes_cli.plugins_cmd import dashboard_install_plugin
entry = catalog.get_live_catalog_entry(name)
if entry is None:
return {"ok": False, "error": "Eklenti katalogda bulunamadı."}
removed = catalog.resolved_removed_entries()
if catalog.match_removed(name, removed) or catalog.match_removed(entry.repo, removed):
return {"ok": False, "error": "Bu eklenti katalogdan kaldırılmış."}
platform = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
supported = {"win": "windows", "win32": "windows", "darwin": "macos", "mac": "macos"}
if entry.platforms and platform not in {supported.get(p.lower(), p.lower()) for p in entry.platforms}:
return {"ok": False, "error": "Bu eklenti bilgisayarın işletim sistemini desteklemiyor."}
if entry.requires_hermes:
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from hermes_cli import __version__
try:
compatible = __version__ in SpecifierSet(entry.requires_hermes)
except InvalidSpecifier:
return {"ok": False, "error": "Katalogdaki sürüm koşulu geçersiz; eklenti kurulmadı."}
if not compatible:
return {"ok": False, "error": f"Bu eklenti Hermes {entry.requires_hermes} gerektiriyor."}
return dashboard_install_plugin(entry.install_identifier, force=force, enable=enable, ref=entry.sha)
+533
View File
@@ -0,0 +1,533 @@
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
(installs of a removed name/repo are refused with the recorded reason). Full policy:
``plugin-catalog/README.md``.
Live refresh: the docs build publishes the same data as ONE JSON document
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
an installed Hermes sees new entries and removals without updating. A fetch failure reuses the last valid
cached copy regardless of age, then falls back to the in-tree copy when no valid cache exists.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
logger = logging.getLogger(__name__)
CATALOG_TIERS = ("official", "community")
# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf
# (the common case for community submissions); "general" is for plugins that fit no shelf.
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
# Offline pins expire, but cached removals remain a permanent kill list.
LIVE_CATALOG_MAX_STALE_SECONDS = 24 * 60 * 60
LIVE_CATALOG_FAILURE_TTL_SECONDS = 60.0
_REQUEST_TIMEOUT = 5.0
_MAX_LIVE_BYTES = 2 * 1024 * 1024
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$")
# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to
# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha.
IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com")
IMAGE_HOST_SUFFIX = ".githubusercontent.com"
def is_allowed_image_url(url: str) -> bool:
from urllib.parse import urlsplit
parts = urlsplit(url)
host = (parts.hostname or "").lower()
return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX))
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
@dataclass
class RemovedEntry:
name: str
repo: str = ""
reason: str = ""
date: str = ""
@dataclass
class CatalogCapabilities:
provides_tools: List[str] = field(default_factory=list)
provides_hooks: List[str] = field(default_factory=list)
provides_middleware: List[str] = field(default_factory=list)
requires_env: List[str] = field(default_factory=list)
@dataclass
class PluginCatalogEntry:
name: str # catalog key, [a-z0-9_-]{1,64}
repo: str # https:// git URL
sha: str # 40-hex pinned commit — mandatory
description: str
maintainer: str
tier: str = "community"
category: str = "desktop"
requires_hermes: str = ""
subdir: str = ""
docs_url: str = ""
version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed
image: str = "" # https image URL on a GitHub host; shown on catalog cards
screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/<name>
readme: bool = False # docs site renders the README from the pinned commit on the entry's page
platforms: List[str] = field(default_factory=list) # empty = all OSes
title: str = "" # human name ("NVIDIA App"); empty = derived from ``name``
onboarding: bool = False # curated: offered on the desktop onboarding card
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
known_issues: List[str] = field(default_factory=list) # #124058: informational; drivers come from plugin-catalog/*.yaml
@property
def install_identifier(self) -> str:
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
def to_dict(self) -> Dict[str, Any]:
caps = self.capabilities
return {
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
"maintainer": self.maintainer, "tier": self.tier, "category": self.category,
"requires_hermes": self.requires_hermes,
"subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image,
"screenshots": list(self.screenshots), "readme": self.readme,
"platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding,
"capabilities": {
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
},
"known_issues": list(self.known_issues),
}
def get_catalog_dir() -> Path:
"""The ``plugin-catalog/`` directory shipped with this checkout."""
return Path(__file__).resolve().parent.parent / "plugin-catalog"
# ── Parsing ──────────────────────────────────────────────────────────────────
def _str_list(raw: Any) -> List[str]:
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
if not isinstance(data, dict):
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
return None
name = str(data.get("name") or "")
repo = str(data.get("repo") or "")
sha = str(data.get("sha") or "").strip().lower()
tier = str(data.get("tier") or "community")
category = str(data.get("category") or "desktop")
problem = (
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})"
if category not in CATALOG_CATEGORIES
else None)
if problem:
logger.warning("Plugin catalog: %s: %s", label, problem)
return None
caps_raw = data.get("capabilities")
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
version = str(data.get("version") or "").strip()
if version and not _VERSION_RE.match(version):
logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version)
version = ""
image = str(data.get("image") or "").strip()
if image and not is_allowed_image_url(image):
logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image)
image = ""
screenshots = [s for s in _str_list(data.get("screenshots")) if is_allowed_image_url(s)]
if len(screenshots) != len(_str_list(data.get("screenshots"))):
logger.warning("Plugin catalog: %s: ignoring screenshots off GitHub hosts", label)
return PluginCatalogEntry(
name=name, repo=repo, sha=sha,
description=str(data.get("description") or "").strip(),
maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category,
requires_hermes=str(data.get("requires_hermes") or "").strip(),
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False,
platforms=_str_list(data.get("platforms")),
title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True,
known_issues=_str_list(data.get("known_issues")),
capabilities=CatalogCapabilities(
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
provides_middleware=_str_list(caps.get("provides_middleware")),
requires_env=_str_list(caps.get("requires_env"))),
)
def _read_yaml(path: Path) -> Any:
try:
return yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
except Exception as exc:
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
return None
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
if not isinstance(raw_list, list):
return []
return [
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
date=str(r.get("date") or ""))
for r in raw_list if isinstance(r, dict) and r.get("name")]
# ── In-tree catalog ──────────────────────────────────────────────────────────
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
Malformed entries are skipped with a warning — never raises."""
root = catalog_dir or get_catalog_dir()
if not root.is_dir():
return []
entries = []
for path in sorted(root.glob("*.yaml")):
if path.name == "removed.yaml":
continue
data = _read_yaml(path)
entry = entry_from_mapping(data, str(path)) if data is not None else None
if entry is not None:
entries.append(entry)
return entries
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
data = _read_yaml(path) if path.is_file() else None
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
q = (query or "").strip().lower()
if not q:
return entries
return [e for e in entries
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
def search_catalog(query: str) -> List[PluginCatalogEntry]:
return filter_entries(load_catalog(), query)
# ── Removed / blocklist ──────────────────────────────────────────────────────
_SCP_URL_RE = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$") # git@host:owner/repo
def _normalize_repo(url: str) -> str:
"""Canonical ``host/path`` for a repo URL: scheme, user, ``www.``, ``.git`` and trailing slashes are
spelling, not identity — the kill list must match ``git@github.com:Evil/Bad.git`` when it names
``https://github.com/evil/bad``."""
from urllib.parse import urlsplit
text = url.strip()
scp = _SCP_URL_RE.match(text)
if scp:
host, path = scp.group(1), scp.group(2)
elif "://" in text:
parts = urlsplit(text)
host, path = parts.hostname or "", parts.path
else:
host, path = "", text
host = host.lower().removeprefix("www.")
path = path.strip("/").removesuffix(".git").rstrip("/").lower()
return f"{host}/{path}" if host else path
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
shipped must still block, and a stale live cache must not un-block an in-tree removal.
"""
if not name_or_repo:
return None
entries = load_removed_list(catalog_dir)
if catalog_dir is None:
entries = entries + live_removed_list()
return match_removed(name_or_repo, entries)
def resolved_removed_entries() -> List[RemovedEntry]:
"""The full kill list (in-tree UNION live) in one resolution. Callers that match many candidates
— e.g. a plugins-hub rebuild annotating every installed plugin — resolve the list once instead
of paying a live-catalog fetch per candidate."""
return load_removed_list() + live_removed_list()
def cached_removed_entries() -> List[RemovedEntry]:
"""In-tree list UNION the last fetched live copy, with NO network round-trip — for the load-time and
``enable`` checks that run in every process and must never block on a dead catalog host."""
cached = _stale_live_cache(_live_cache_path()) or {}
return load_removed_list() + _removed_from_list(cached.get("removed"))
def match_removed(
candidate: str, entries: List[RemovedEntry]
) -> Optional[RemovedEntry]:
"""One candidate against a pre-resolved kill list: exact name or normalized repo URL match."""
if not candidate:
return None
text = candidate.strip()
text_repo = _normalize_repo(text)
for entry in entries:
if text == entry.name or (
entry.repo and text_repo == _normalize_repo(entry.repo)
):
return entry
return None
# ── Live catalog ─────────────────────────────────────────────────────────────
def _live_cache_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "plugin-catalog.json"
def invalidate_live_cache_for_home(home: Path) -> None:
"""Best-effort removal of the cached live catalog under *home* (any profile's home).
``hermes update`` drops it for every profile after the checkout changes: a snapshot fetched
before the bump would otherwise out-vote the newer in-tree catalog (pins the update just
changed, entries it just added) for the rest of :data:`LIVE_CATALOG_TTL_SECONDS` (#119340).
The next :func:`fetch_live_catalog` re-fetches the published doc, or falls back to the
in-tree catalog while the network is down — both newer than what was deleted. Safe when the
cache is absent (first run, other profiles that never opened the plugins hub).
"""
try:
(Path(home) / "cache" / "plugin-catalog.json").unlink(missing_ok=True)
except Exception as exc:
logger.debug("Plugin catalog: could not drop the live cache under %s: %s", home, exc)
# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one
# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per
# installed plugin), so the dashboard event loop stalled for minutes.
_live_fetch_failed_until = 0.0
def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]:
"""A previously fetched copy still beats the in-tree one when the network is down — for
:data:`LIVE_CATALOG_MAX_STALE_SECONDS`. Past that its pins may trail the checkout's own catalog
(a 90-day-old cache outranked a freshly updated in-tree pin), so the entries are dropped and the
caller falls back to in-tree; the removals are kept."""
try:
if not cache.is_file():
return None
data = json.loads(cache.read_text(encoding="utf-8-sig"))
if time.time() - cache.stat().st_mtime > LIVE_CATALOG_MAX_STALE_SECONDS:
data = {**data, "entries": []}
return data
except Exception:
return None
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
back to the in-tree catalog. A failed network attempt is remembered for
:data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one
per call (``force`` bypasses both caches)."""
global _live_fetch_failed_until
cache = _live_cache_path()
try:
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
return json.loads(cache.read_text(encoding="utf-8-sig"))
except Exception as exc:
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
if not force and time.time() < _live_fetch_failed_until:
return _stale_live_cache(cache)
try:
import httpx
from hermes_constants import mkdir_under_hermes_home
from utils import atomic_write_text
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
if len(resp.content) > _MAX_LIVE_BYTES:
raise ValueError("live catalog payload too large")
data = resp.json()
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
raise ValueError("unexpected live catalog payload")
mkdir_under_hermes_home(cache.parent)
# Atomic: a concurrent reader (gateway, TUI, a second CLI) must never see a half-written
# document, which would read as a fetch failure and start its own 60 s failure window.
atomic_write_text(cache, json.dumps(data), tmp_prefix=f"{cache.name}.tmp-")
return data
except Exception as exc:
logger.debug("Plugin catalog: live fetch failed: %s", exc)
_live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
return _stale_live_cache(cache)
_in_tree_catalog_time: Optional[float] = -1.0 # -1 = not resolved yet; None = no usable in-tree time
def in_tree_catalog_time() -> Optional[float]:
"""Commit time (epoch) of the last change to this checkout's ``plugin-catalog/``, or ``None`` when
the install is not a git checkout (a release/pip install cannot be newer than the published doc).
On a git checkout whose path history is unreadable without network — the treeless ``tree:0`` layout
``hermes update`` produces — the newest checked-out catalog file's mtime stands in for it, so a
freshly updated checkout still outranks a doc fetched before the bump. Resolved once per process."""
global _in_tree_catalog_time
if _in_tree_catalog_time != -1.0:
return _in_tree_catalog_time
root = get_catalog_dir().parent
resolved: Optional[float] = None
if (root / ".git").exists():
try:
from hermes_cli._subprocess_compat import bounded_git_probe
# Path history needs missing trees on tree:0 clones. The shared probe disables
# lazy fetch only for its child and bounds timeout cleanup on every platform.
out = bounded_git_probe(
["git", "-C", str(root), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
timeout=10,
)
resolved = float(out) if out else None
except Exception as exc:
logger.debug("Plugin catalog: could not date the in-tree catalog: %s", exc)
if resolved is None:
# "History unreadable without network" must not collapse into "not a git checkout":
# ``None`` feeds the frozen-copy rule that defers to the live doc, which would let a
# cache fetched before ``hermes update`` re-pin the old sha on exactly the installs
# that just bumped it. A treeless clone still has its checked-out files — only the
# historical trees are missing — and their mtime dates the checkout that wrote them.
resolved = _catalog_worktree_mtime()
_in_tree_catalog_time = resolved
return resolved
def _catalog_worktree_mtime() -> Optional[float]:
"""Newest mtime under the checkout's ``plugin-catalog/``: a no-network freshness signal for clones
whose path history is unreachable (treeless or offline). ``None`` when nothing is checked out."""
try:
times = [p.stat().st_mtime for p in get_catalog_dir().rglob("*") if p.is_file()]
except OSError:
return None
return max(times) if times else None
def _live_generated_time(data: Dict[str, Any]) -> Optional[float]:
raw = data.get("generated_at")
if not isinstance(raw, str) or not raw:
return None
try:
from datetime import datetime, timezone
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).timestamp()
except ValueError:
return None
def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool:
"""For one entry present in both sources with a different pin: the newer catalog wins. Newer is
decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve;
otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release
install's in-tree copy is frozen at release time)."""
if tree.sha == live.sha:
return False
if tree_is_newer is not None:
return tree_is_newer
if tree.version and live.version:
try:
from packaging.version import Version
return Version(tree.version) > Version(live.version)
except Exception:
return False
return False
def load_catalog_live() -> List[PluginCatalogEntry]:
"""Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at
different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin,
a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`)."""
data = fetch_live_catalog()
if data is None:
return load_catalog()
entries = [e for i, raw in enumerate(data["entries"])
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
if not entries:
return load_catalog()
in_tree = {e.name: e for e in load_catalog()}
live_t, tree_t = _live_generated_time(data), in_tree_catalog_time()
tree_is_newer = (tree_t > live_t) if (live_t is not None and tree_t is not None) else None
raw_by_name = {str(raw.get("name")): raw for raw in data["entries"] if isinstance(raw, dict)}
return [in_tree[e.name] if e.name in in_tree and _prefer_in_tree_entry(in_tree[e.name], e, tree_is_newer)
else _with_curated_fields(e, in_tree.get(e.name), raw_by_name.get(e.name) or {})
for e in entries]
# Curated display fields a published doc older than the field does not carry. ``generated_at`` is the
# docs build time, not the content time, so a rebuild of an older catalog outranks a checkout that added
# the field; a doc that has the key (even ``false``) decides.
_CURATED_FIELDS = ("onboarding", "title")
def _with_curated_fields(live: PluginCatalogEntry, tree: Optional[PluginCatalogEntry], raw: Dict[str, Any]
) -> PluginCatalogEntry:
if tree is None or tree.sha != live.sha:
return live
missing = {key: getattr(tree, key) for key in _CURATED_FIELDS if key not in raw}
return dataclasses.replace(live, **missing) if missing else live
def live_removed_list() -> List[RemovedEntry]:
data = fetch_live_catalog()
return _removed_from_list(data.get("removed")) if data else []
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
return next((e for e in load_catalog_live() if e.name == name), None)
# ── Human summaries ──────────────────────────────────────────────────────────
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
"""One paragraph shown at install/enable prompts: what the user is granting."""
caps = entry.capabilities
parts = [f"{label} {', '.join(items)}" for label, items in (
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
if entry.description:
bits.append(entry.description)
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
if entry.platforms:
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
if entry.requires_hermes:
bits.append(f"Requires Hermes {entry.requires_hermes}.")
if entry.known_issues:
# #124058: informational — the catalog documents traps (unsupported
# install-method/mode combinations); surface them at install prompts
# without blocking the install.
bits.append(f"Known issues: {'; '.join(entry.known_issues)}.")
return " ".join(bits)
+21 -3
View File
@@ -564,6 +564,22 @@ def _install_metadata_path() -> Path:
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
def _remove_plugin_tree(path: Path) -> None:
"""Remove one validated plugin tree, including read-only Git objects on Windows."""
import stat
target = path.resolve()
if _plugins_dir().resolve() not in target.parents:
raise ValueError("Plugin cleanup must stay strictly inside the plugin directory")
def retry_readonly(function, failed_path, exc_info):
if os.name != "nt" or not isinstance(exc_info[1], PermissionError):
raise exc_info[1]
os.chmod(failed_path, stat.S_IRUSR | stat.S_IWUSR)
function(failed_path)
shutil.rmtree(path, onerror=retry_readonly)
def _read_install_metadata() -> dict[str, dict[str, object]]:
"""Read profile-local, non-secret plugin source metadata from disk."""
path = _install_metadata_path()
@@ -867,7 +883,7 @@ def _install_plugin_core(
_write_install_metadata(new_metadata)
except Exception:
if target.exists():
shutil.rmtree(target)
_remove_plugin_tree(target)
if replaced_existing and backup.exists():
os.replace(backup, target)
if old_metadata:
@@ -1208,7 +1224,7 @@ def _remove_plugin_core(target: Path) -> None:
"""Remove one plugin and its metadata without splitting their state."""
metadata = _read_install_metadata()
if target.name not in metadata:
shutil.rmtree(target)
_remove_plugin_tree(target)
return
updated = dict(metadata)
@@ -1230,7 +1246,7 @@ def _remove_plugin_core(target: Path) -> None:
) from restore_exc
shutil.rmtree(staging, ignore_errors=True)
raise
shutil.rmtree(staging)
_remove_plugin_tree(staging)
def cmd_remove(name: str) -> None:
@@ -2641,6 +2657,7 @@ def dashboard_install_plugin(
*,
force: bool,
enable: bool,
ref: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
@@ -2657,6 +2674,7 @@ def dashboard_install_plugin(
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
ref=ref,
)
except PluginScanBlocked as exc:
findings = []
+3 -2
View File
@@ -747,9 +747,11 @@ class FontSetBody(BaseModel):
# --- from web_server.py (originally lines 19681-19684) ---
class _AgentPluginInstallBody(BaseModel):
identifier: str
identifier: str = ""
force: bool = False
enable: bool = True
catalog_name: Optional[str] = None
ref: Optional[str] = None
# --- from web_server.py (originally lines 19896-19898) ---
@@ -763,4 +765,3 @@ class _PluginProvidersPutBody(BaseModel):
class _PluginVisibilityBody(BaseModel):
hidden: bool
+17 -5
View File
@@ -19070,16 +19070,28 @@ async def get_plugins_hub(request: Request):
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
@app.get("/api/dashboard/plugins/catalog")
async def get_plugins_catalog(request: Request):
_require_token(request)
from hermes_cli.aiturk_plugin_catalog import catalog_payload
return await asyncio.to_thread(catalog_payload)
@app.post("/api/dashboard/agent-plugins/install")
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
_require_token(request)
from hermes_cli.plugins_cmd import dashboard_install_plugin
result = dashboard_install_plugin(
body.identifier.strip(),
force=body.force,
enable=body.enable,
)
if body.catalog_name:
from hermes_cli.aiturk_plugin_catalog import install_catalog_plugin
result = await asyncio.to_thread(
install_catalog_plugin, body.catalog_name, force=body.force, enable=body.enable,
)
else:
result = await asyncio.to_thread(
dashboard_install_plugin, body.identifier.strip(), force=body.force,
enable=body.enable, ref=body.ref,
)
if not result.get("ok"):
raise HTTPException(
status_code=400,
+1 -1
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.17",
"version": "1.0.0-beta.23",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",
+194
View File
@@ -0,0 +1,194 @@
# Hermes Plugin Catalog
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
(except `removed.yaml`) is one catalog entry, discoverable via
`hermes plugins catalog` / `hermes plugins search` and installable with
`hermes plugins install <name>`.
## Admission policy
Presence in this directory **is** the trust signal. The rules that keep it
meaningful:
<!-- admission-rules:start (mirrored in website/docs/developer-guide/plugins/catalog-submission.md; tests/website/test_catalog_rules_mirror.py keeps them identical) -->
1. **Human-merged gate.** Entries are added *only* via a PR to the
`hermes-agent` repository, reviewed and merged by a maintainer. There is
no self-serve registry, no automated ingestion.
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
Installs clone the repository and check out exactly that commit.
3. **No self-updating code.** A listed plugin must not fetch and replace
its own files (in-app "check for updates", signed release downloaders,
remote `plugin.js` loaders). The exact SHA pin *is* the trust model; a
self-updater lets an installed copy move to a commit nobody reviewed.
Updates reach users only through a SHA-bump PR here plus
`hermes plugins update <name>`. Keep the updater in the standalone
distribution if you want one; strip it from the catalog build.
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
expected to look at the upstream commit range being adopted.
5. **Owner-or-major-contributor submissions, or a maintainer-curated sweep.**
An entry may be submitted by the plugin repository's owner or a major
contributor to it; drive-by submissions of third-party repos are declined.
Hermes maintainers may also add entries in batches from a reviewed sweep
of community plugins (every pin validated and scanned at the pinned
commit, self-updater and credential-store checks run, English-first UI).
Authors of swept-in entries keep control: a PR from the owner adjusting
or removing their entry is accepted on request, and SHA bumps stay
owner-or-maintainer PRs under rule 4.
6. **Declared capabilities must match reality.** The `capabilities:` block
(tools, hooks, middleware, env vars) must match what the plugin actually
registers at the pinned commit. Validation fails the entry otherwise —
undeclared capability creep is treated as a security issue.
7. **The install scanner runs at admission.** `hermes plugins validate` includes
the `security scan` check: `dangerous` fails the entry; `caution` findings
appear as warnings in the CI log and the reviewer reads them before merging.
In exchange, installs at the pinned SHA accept `caution` without a prompt
(`dangerous` still blocks). Review the warnings; do not merge past them.
8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs
in the Desktop renderer with the app's full authority (the loader isolates
errors, not capabilities), so a listed one may only use the plugin SDK:
no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`),
no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk`
/ `react` (app bundle chunks, blob or http URLs included), no script-tag
injection, no reaching into the app's internal stores or its own markup
(querying `data-slot` / `data-tour` / `data-sidebar` / `data-testid`
elements from `document`, or a `document.body` MutationObserver, to restyle,
hide, click or rewrite core UI). `hermes plugins validate` refuses these at
admission (`desktop surface` check); a plugin that needs a capability the
SDK lacks asks for an SDK hook instead of patching around it.
9. **No runtime overrides of Hermes core.** A listed plugin extends Hermes only
through public surfaces: hooks, middleware, provider profiles and the
other `register_*` APIs, and Desktop SDK slots and routes. It must not
replace, wrap or rebind core functions, methods, module attributes or
private dicts in place (`AIAgent.<method> = ...`, `setattr(server, ...)`,
`sys.modules[...]`, writes into a core module's tables). Two plugins
patching the same seam silently break each other, and every core release
can break both. `hermes plugins validate` refuses these at admission (`no
core override` check). If the hook you need does not exist, open an issue
describing it: we would rather add the seam than list a patch.
10. **Dependency security policy is the plugin's.** Hermes's 14-day
`exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's
`python_dependencies` / `pyproject.toml` install under the plugin's policy
(no quarantine, still inside Hermes's core constraints). Reviewers read the
dependency list at the pinned SHA: bare floors (`>=X` with no upper bound)
and floors on the newest release get a request for the oldest
API-compatible floor plus an upper bound, and authors are strongly
recommended to run their own release quarantine (`uv --exclude-newer` in
their CI) — see the developer guide's *Dependency security policy*. A
recent floor alone is not grounds to hold an entry.
11. **Credentials stay with their owner.** A plugin reads the credentials it is
configured with: the env vars in `requires_env` and its own `config_schema`
secrets. Reading another tool's login (a vendor CLI's token file, a browser
profile) must be disclosed in the PR and is a trust-tier call for a
maintainer. Refreshing, rotating or writing another client's OAuth tokens, or
presenting itself as another vendor's client, is not admitted without an
explicit maintainer ruling; a read-only build is the usual way through.
12. **Approvals and unattended runs are respected.** A plugin never routes around
Hermes's approval system: no auto-approving, no disabling guards, and no
spawning Hermes or shell children that inherit YOLO or non-interactive mode
to run commands nobody approved. Anything that waits for a person (a prompt,
an OAuth browser flow) fails cleanly or times out under cron, the messaging
gateway and other unattended runs instead of hanging the agent.
13. **Risky behaviour is disclosed.** What a user would want to know before
installing goes in the PR description and the plugin's README: network calls
to third-party services, reads outside the plugin's own data, shell commands,
long-running background processes, stored credentials. Telemetry and usage
reporting are opt-in. Reviewers summarise these as disclosure lines on the
entry PR; undisclosed behaviour found in review is a request for changes.
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
(the loader skips the plugin otherwise). `version` matches the pinned code,
and Python dependencies resolve under Hermes's core constraints
(`hermes plugins validate --install-deps` is what CI runs).
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
are not listed under Nous branding.
16. **One listing per plugin lineage.** A fork of a listed community plugin is
listed only when it is materially different from the original: a different
transport or architecture, or capability the original lacks and its author
declined or has not answered a PR for 30 days. Improvements to a listed plugin
go upstream as a PR to its author. A fork that renames, rebrands or adds small
changes is declined in favour of the original. A listed fork names its origin
in its disclosure line (`Derived from <entry>`).
<!-- admission-rules:end -->
The step-by-step submission guide, with the same rules and what reviewers check,
lives at
[Submitting to the plugin catalog](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission).
## Entry schema
```yaml
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
repo: https://github.com/owner/repo # https:// only
sha: <40-hex commit sha> # mandatory exact pin
subdir: "" # optional; plain relative path inside the repo
# ([A-Za-z0-9._/-]+ only: no '..', '.', empty
# segments, absolute, or backslash forms)
description: One-line description.
maintainer: OwnerName
tier: official # official | community (default community)
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
# (default desktop) — the shelf the entry sits on at /docs/plugins
requires_hermes: ">=0.19" # optional
docs_url: "" # optional
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
# https://raw.githubusercontent.com/owner/repo/<sha>/docs/banner.png
screenshots: [] # optional, up to 6 https images on a GitHub host; gallery on /docs/plugins/<name>
readme: true # optional, default true; the README at the PINNED SHA renders on /docs/plugins/<name>
platforms: [] # optional, e.g. [linux, macos]; empty = all
title: "" # optional human name on cards ("NVIDIA App"); default = name
onboarding: false # optional; offered on the Desktop onboarding card (official entries only)
known_issues: [] # optional strings shown at the install prompt; informational, never blocks
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
```
`version`, `image`, `screenshots` and `readme` are cosmetic: none is parsed or
used to pick what installs. The sha stays the release; bump `version` in the
same PR that bumps `sha` so the label on the card matches the code. Images must
live on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com`
so the Desktop catalog and the docs site never fetch from third-party hosts;
pin the raw URL to the entry's commit and the picture is as immutable as the
code.
Every entry gets a page at `https://hermes-agent.nousresearch.com/docs/plugins/<name>`
and every maintainer a page at `/docs/plugins/by/<maintainer>`, both generated
from these files at docs build time. `screenshots:` fills the page's gallery;
the build fetches the README (from `subdir` if set, else the repo root) **at the
pinned sha** — GitHub and GitLab repos, on by default, `readme: false` opts out — and renders it
through an allowlist (raw HTML dropped, links and images resolved against the
pinned tree). The page therefore shows the README the reviewer read, and it
changes only when a reviewed re-pin lands.
## removed.yaml — the blocklist
When an entry is pulled from the catalog for security or policy reasons, it
is recorded in `removed.yaml` with a reason and date. The installer refuses
to install anything matching a removed entry's name or repo URL, so a
malicious plugin cannot be re-installed from a stale identifier after
removal. A plugin that lands on the list after a user installed it stops
updating, cannot be enabled and is refused at load time. Removals, like
additions, land via reviewed PRs.
Delisting is different from removal: an entry that is unmaintained, superseded, squatting a
name it is not affiliated with, or no longer meets a rule is deleted from the catalog (plain
file removal, users who already installed it are unaffected). It is welcome back once the
problem is fixed; a name squatter comes back under a distinct name. When a new rule affects
entries that are already listed, their authors get an issue on their repository explaining
the change and time to update before anything is delisted.
## Names
The catalog key and the manifest `name:` are what users search, install, and — for memory
providers — put in `memory.provider`. A `memory` / `exclusive` entry must not reuse the name of
another provider or of a well-known upstream project it is not affiliated with: two providers
registering the same `register_memory_provider` name make `memory.provider` ambiguous
(whichever loads last wins). Reviewers check the registered provider name, not just the file
name, and the affiliated project gets the bare key.
+23
View File
@@ -0,0 +1,23 @@
name: blender
repo: https://github.com/NousResearch/hermes-plugin-blender
sha: d1929b593bddd2241a8939fb355e0a69b8e077af
version: "0.1.0"
description: >-
Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3,
with a Blender workflow skill. Requires Blender 5.1+, its separately
installed MCP add-on, Git and uv. First startup downloads Python dependencies;
prepare the uv environment before connecting. Disclosure: tools execute
arbitrary Python in Blender with the user's privileges. No automatic
application/version/liveness gate; server discovery does not prove scene readiness.
title: Blender
maintainer: NousResearch
tier: official
category: tools
docs_url: https://github.com/NousResearch/hermes-plugin-blender#readme
platforms: [windows, macos, linux]
onboarding: true
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
@@ -0,0 +1,22 @@
name: claude-subscription-directsdk
repo: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk
sha: 31b591fd04737a7807183f3d7f3d389b19f94687
description: Experimental model provider that runs Hermes turns on a Claude Pro/Max subscription through
the official Claude Code CLI (the Agent SDK path), so no separate API key or per-token bill. Requires
the claude CLI installed and logged in; the plugin checks for it at load, in hermes model, in the pickers
and before every request, and refuses cleanly with the install hint when it is missing. Disclosure — every
turn is billed against your Claude subscription's Agent SDK allowance, and the provider inherits whatever
account claude is logged into.
maintainer: NousResearch
tier: official
category: models
requires_hermes: ">=0.21.4"
docs_url: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk#readme
version: "0.3.2"
platforms: []
image: https://raw.githubusercontent.com/NousResearch/hermes-plugin-claude-subscription-directsdk/31b591fd04737a7807183f3d7f3d389b19f94687/assets/card.png
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+15
View File
@@ -0,0 +1,15 @@
name: hermes-memory-wiki
repo: https://github.com/NousResearch/hermes-memory-wiki
sha: 9bc3913b8474eaf4d7eec32e97af4d77957c36df
description: 'Memory Wiki dashboard tab: browsable subject pages and daily logs derived from local session
history, plus a read-only Persistent Memory (MEMORY.md / USER.md) audit panel.'
maintainer: NousResearch
tier: official
requires_hermes: ">=0.21"
docs_url: https://github.com/NousResearch/hermes-memory-wiki#readme
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
@@ -0,0 +1,15 @@
name: hermes-telegram-business
repo: https://github.com/NousResearch/hermes-telegram-business
sha: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
description: 'Observe-with-approval Telegram Business Mode (secretary bot): every drafted customer reply
needs owner approval before it is sent.'
maintainer: NousResearch
tier: official
category: platform
docs_url: https://github.com/NousResearch/hermes-telegram-business
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+23
View File
@@ -0,0 +1,23 @@
name: homeassistant
repo: https://github.com/NousResearch/hermes-homeassistant
sha: ba30cb0cf86c52bdb5cde98974bc062e97966529
description: 'Home Assistant gateway platform (state-change events in, persistent notifications and cron notify
delivery out) plus the homeassistant toolset (ha_list_entities, ha_get_state, ha_list_services, ha_call_service).
Formerly built into Hermes core; existing users are migrated automatically.'
maintainer: NousResearch
tier: official
category: platform
requires_hermes: ">=0.21.5"
version: "2.0.1"
docs_url: https://github.com/NousResearch/hermes-homeassistant
platforms: []
capabilities:
provides_tools:
- ha_list_entities
- ha_get_state
- ha_list_services
- ha_call_service
provides_hooks: []
provides_middleware: []
requires_env:
- HASS_TOKEN
+24
View File
@@ -0,0 +1,24 @@
name: nvidia-app
repo: https://github.com/NousResearch/hermes-nvidia
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
subdir: nvidia-app
description: "NVIDIA App control through its local MCP server: overlay capture and state, driver status
and release notes, game optimization, and app launch. Requires NVIDIA App 11.0 or newer on Windows;
the install is refused when the app is absent, and the tools appear while the app's service is running.
The plugin reads the endpoint and token NVIDIA App publishes in its own McpServer server.json on every
connection, so a changed port or token needs no reconfiguration. Disclosure: the tools read and change
NVIDIA App settings on this machine; nothing leaves loopback."
title: NVIDIA App
maintainer: NousResearch
tier: official
category: tools
requires_hermes: ">=0.21.4"
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-app
version: "0.1.0"
platforms: [windows]
onboarding: true
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+24
View File
@@ -0,0 +1,24 @@
name: nvidia-broadcast
repo: https://github.com/NousResearch/hermes-nvidia
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
subdir: nvidia-broadcast
description: "NVIDIA Broadcast control through its local MCP gateway: camera, microphone and speaker
effects, Studio Voice profiles, device selection, camera resolution, and effects on local media files.
Requires NVIDIA Broadcast 2.3 or newer on Windows (2.2 and older have no gateway); the install is
refused when the app is absent, and the tools appear while NVIDIA Broadcast is running in the user's
desktop session. Disclosure: the tools change live audio and video effects on this machine; nothing
leaves loopback."
title: NVIDIA Broadcast
maintainer: NousResearch
tier: official
category: tools
requires_hermes: ">=0.21.4"
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-broadcast
version: "0.1.0"
platforms: [windows]
onboarding: true
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+12
View File
@@ -0,0 +1,12 @@
# Blocklist for plugins pulled from the catalog for security or policy
# reasons. The installer refuses to install anything whose name or repo URL
# matches an entry here (unless the caller explicitly bypasses the check).
# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs,
# same as additions.
#
# Name only. Do not set repo: the replacement entry is the same repository,
# and a repo match would refuse that install too.
removed:
- name: or-picker
reason: "Folded into openrouter-picker. Installing the provider alone left the picker broken. Install openrouter-picker."
date: "2026-09-28"
+17
View File
@@ -0,0 +1,17 @@
name: snyk
repo: https://github.com/NousResearch/hermes-plugin-snyk
sha: 2a41a07f81e45125bf82a19af1b13396ace4b81f
description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs with Snyk through its
first-party MCP server (pinned snyk@1.1306.0 over npx stdio, CLI analytics disabled), with the
snyk-security-scan workflow skill bundled. Portable Agent Plugins v1 package (mcp.json + skills/);
needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).'
maintainer: NousResearch
tier: official
category: tools
docs_url: https://github.com/NousResearch/hermes-plugin-snyk
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+16
View File
@@ -0,0 +1,16 @@
name: touchdesigner
repo: https://github.com/NousResearch/hermes-plugin-touchdesigner
sha: 88a7a7884e5eda33cfe4b1dd5e69df7cb15d5898
description: 'Drive a live TouchDesigner session through the twozero MCP server (Streamable HTTP on
127.0.0.1:40404), with the touchdesigner-mcp workflow skill bundled. Portable Agent Plugins v1 package
(mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.'
maintainer: NousResearch
tier: official
category: tools
docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+1
View File
@@ -272,6 +272,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_REAL_HOME",
"TERMINAL_HOME_MODE",
"HERMES_INTERACTIVE",
"HERMES_SINGLE_QUERY_SESSION",
"HERMES_QUIET",
"HERMES_TOOL_PROGRESS",
"HERMES_TOOL_PROGRESS_MODE",
@@ -0,0 +1,56 @@
from hermes_cli import aiturk_plugin_catalog as adapter
from hermes_cli import plugin_catalog as catalog
from hermes_cli import plugins_cmd
SHA = "a" * 40
def entry(**extra):
return catalog.entry_from_mapping(dict(name="demo", repo="https://github.com/owner/demo",
sha=SHA, description="test", maintainer="owner", tier="community", **extra), "test")
def test_install_uses_catalog_pin(monkeypatch):
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry())
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
seen = []
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *args, **kwargs: seen.append((args, kwargs)) or {"ok": True})
assert adapter.install_catalog_plugin("demo")["ok"]
assert seen == [(("https://github.com/owner/demo",), {"force": False, "enable": True, "ref": SHA})]
def test_missing_or_incompatible_catalog_entry_is_rejected(monkeypatch):
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: None)
assert not adapter.install_catalog_plugin("missing")["ok"]
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(requires_hermes=">=999.0"))
assert not adapter.install_catalog_plugin("demo")["ok"]
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(platforms=["nonexistent"]))
assert not adapter.install_catalog_plugin("demo")["ok"]
def test_payload_tracks_installed_revision(monkeypatch):
monkeypatch.setattr(catalog, "load_catalog_live", lambda: [entry()])
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: [("demo",)])
monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {"demo": {"revision": "b" * 40}})
row = adapter.catalog_payload()["entries"][0]
assert row["installed"] and row["update_available"] and row["sha"] == SHA
def test_catalog_http_auth_and_install_contract(monkeypatch):
from fastapi.testclient import TestClient
from hermes_cli import web_server as web
client = TestClient(web.app)
assert client.get("/api/dashboard/plugins/catalog").status_code in (401, 403)
client.headers[web._SESSION_HEADER_NAME] = web._SESSION_TOKEN
monkeypatch.setattr(adapter, "catalog_payload", lambda: {"entries": [entry().to_dict()], "removed": []})
response = client.get("/api/dashboard/plugins/catalog")
assert response.status_code == 200 and response.json()["entries"][0]["sha"] == SHA
seen = []
monkeypatch.setattr(adapter, "install_catalog_plugin", lambda name, **kwargs: seen.append(name) or {"ok": True, "after_install_path": "private"})
monkeypatch.setattr(web, "_get_dashboard_plugins", lambda **kwargs: [])
monkeypatch.setattr(web, "_invalidate_plugins_hub_cache", lambda: None)
response = client.post("/api/dashboard/agent-plugins/install", json={"catalog_name": "demo", "identifier": "https://untrusted.invalid/repo"})
assert response.status_code == 200 and seen == ["demo"]
assert "after_install_path" not in response.json()
+300
View File
@@ -0,0 +1,300 @@
"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are
skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree."""
from __future__ import annotations
import json
import os
import pytest
import yaml
from hermes_cli import plugin_catalog as pc
SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
def _entry(name="good-plugin", **over):
data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d",
"maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}}
data.update(over)
return data
def test_shipped_catalog_entries_are_all_valid_and_pinned():
"""Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent
shipping regression the admission CI only catches on changed files."""
root = pc.get_catalog_dir()
files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"]
entries = pc.load_catalog()
assert len(entries) == len(files) >= 1
assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries)
assert all(e.install_identifier.startswith(e.repo) for e in entries)
def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path):
"""``category`` is the browse shelf: absent → ``desktop``; a value outside CATALOG_CATEGORIES is an
invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site."""
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory")))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory")))
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
assert set(entries) == {"no-cat", "mem"}
assert entries["no-cat"].category == "desktop" and entries["mem"].category == "memory"
assert entries["mem"].to_dict()["category"] == "memory"
def test_version_and_image_are_cosmetic_and_offhost_images_are_dropped(tmp_path):
"""``version``/``image`` label the pin, they never gate it: a bad value is dropped with a warning and
the entry survives; an image off GitHub is dropped because the Desktop browser must never fetch
from third-party hosts."""
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("offhost", version="1.4.0 beta", image="https://evil.example/x.png")))
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
assert set(entries) == {"labelled", "offhost"}
assert entries["labelled"].version == "1.4.0" and entries["labelled"].image == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"
assert entries["offhost"].version == "" and entries["offhost"].image == ""
assert entries["labelled"].to_dict()["version"] == "1.4.0"
def test_screenshots_and_readme_are_parsed_and_readme_defaults_on(tmp_path):
shot = "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/docs/1.png"
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("paged", screenshots=[shot, "https://evil.example/x.png"], readme=True)))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("plain", readme=False)))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("bare")))
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
assert entries["paged"].screenshots == [shot] and entries["paged"].readme is True
assert entries["plain"].screenshots == [] and entries["plain"].readme is False
assert entries["bare"].readme is True
assert entries["paged"].to_dict()["screenshots"] == [shot] and entries["paged"].to_dict()["readme"] is True
def test_invalid_entries_are_skipped_not_raised(tmp_path):
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok", description="café")), encoding="utf-8-sig")
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y")))
(tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name")))
(tmp_path / "e.yaml").write_text("- not\n- a mapping\n")
entries = pc.load_catalog(tmp_path)
assert [e.name for e in entries] == ["ok"]
assert entries[0].description == "café"
def test_find_removed_matches_name_or_normalized_repo(tmp_path):
(tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [
{"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]}))
assert pc.find_removed("evil", tmp_path).reason == "malware"
assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None
assert pc.find_removed("https://github.com/x/fine", tmp_path) is None
# Scheme/host/user spellings are not identity: every git way of naming the repo is blocked.
for spelling in ("git@github.com:x/evil.git", "ssh://git@github.com/x/evil", "http://github.com/x/evil",
"https://www.github.com/x/evil/", "git://github.com/x/evil.git"):
assert pc.find_removed(spelling, tmp_path) is not None, spelling
assert pc.find_removed("git@gitlab.com:x/evil.git", tmp_path) is None # different host stays distinct
@pytest.mark.parametrize("stale", [False, True])
def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch, stale):
"""Offline live pins expire, but a cached removal keeps blocking installs."""
import httpx
requests = []
get = httpx.get
def record_get(*args, **kwargs):
requests.append(args)
return get(*args, **kwargs)
monkeypatch.setattr(httpx, "get", record_get)
cache = tmp_path / "cache" / "plugin-catalog.json"
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps({"entries": [_entry("live-only", description="café")],
"removed": [{"name": "pulled-live", "reason": "cve"}]},
ensure_ascii=False), encoding="utf-8-sig")
if stale:
os.utime(cache, (1, 1))
requests.clear()
# The unreachable fetch above armed the failure window; a stale cache must still
# trigger a real retry once that window has passed.
monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0)
entries = pc.load_catalog_live()
if stale:
assert [e.name for e in entries] == [e.name for e in pc.load_catalog()]
else:
assert [e.name for e in entries] == ["live-only"]
assert entries[0].description == "café"
assert bool(requests) is stale
assert pc.find_removed("pulled-live").reason == "cve"
cache.write_text("{", encoding="utf-8-sig")
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
def _fresh_cache(tmp_path, monkeypatch, doc: dict):
cache = tmp_path / "cache" / "plugin-catalog.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(doc))
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # never reached / unreachable
return cache
def test_newer_in_tree_pin_outranks_a_fresh_live_cache(tmp_path, monkeypatch):
"""Right after `hermes update` bumps an in-tree pin, a live cache fetched BEFORE the bump must not
re-install the old sha: for the same entry the newer catalog wins (checkout catalog commit time vs
the doc's generated_at). Live-only entries survive; an older checkout still defers to the doc."""
old, new = SHA, "a" * 40
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
"entries": [_entry("shared", sha=old), _entry("live-only")], "removed": []})
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
bump_time = pc._live_generated_time({"generated_at": "2026-09-22T10:00:00Z"}) + 3600
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time)
by_name = {e.name: e for e in pc.load_catalog_live()}
assert by_name["shared"].sha == new and "live-only" in by_name
# Control: a checkout whose catalog predates the doc takes the live pin.
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time - 7200)
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
def test_live_cache_write_never_truncates_the_previous_copy(tmp_path, monkeypatch):
"""The cache is replaced atomically: a reader racing the writer (or a write that dies half-way)
sees the whole previous document, never a truncated one."""
cache = _fresh_cache(tmp_path, monkeypatch, {"entries": [_entry("previous")], "removed": []})
previous = cache.read_text()
class _Resp:
content = json.dumps({"entries": [_entry("fresh")], "removed": []}).encode()
def raise_for_status(self): pass
def json(self): return json.loads(self.content)
import httpx
import utils
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp())
pc._live_fetch_failed_until = 0.0
def _dies(*_a, **_k):
raise OSError("disk full")
monkeypatch.setattr(utils, "atomic_replace", _dies)
pc.fetch_live_catalog(force=True) # the failed write is swallowed like any other fetch failure
assert cache.read_text() == previous
assert not list(cache.parent.glob("*.tmp*")) # no leftover temp file either
def test_curated_fields_the_published_doc_lacks_come_from_the_checkout(tmp_path, monkeypatch):
"""The docs build stamps generated_at at build time, so a doc rebuilt from an older catalog is "newer"
than a checkout that just added `onboarding`. At the same pin, a curated field the doc does not carry
comes from the checkout; a doc that carries it (even false) decides, and a different pin never merges."""
live = [_entry("same"), {**_entry("says-no"), "onboarding": False}, _entry("repinned", sha="b" * 40)]
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", "entries": live, "removed": []})
tree = [pc.entry_from_mapping({**_entry(n), "onboarding": True, "title": "T"}, n) for n in ("same", "says-no", "repinned")]
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: tree)
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: 0.0) # checkout older than the doc
by_name = {e.name: e for e in pc.load_catalog_live()}
assert (by_name["same"].onboarding, by_name["same"].title) == (True, "T")
assert by_name["says-no"].onboarding is False and by_name["says-no"].title == "T"
assert by_name["repinned"].onboarding is False and by_name["repinned"].sha == "b" * 40
def test_in_tree_catalog_time_does_not_lazy_fetch_on_treeless_clones(tmp_path, monkeypatch):
"""``in_tree_catalog_time`` dates the checkout with a pathspec'd ``git log``; the pathspec makes git
open every commit's tree. On a treeless (``tree:0``) partial clone — the layout ``hermes update``
produces — none of those trees exist locally, so an unrestricted probe lazy-fetches each one against
the remote: measured 40-80 s per inventory request, past the desktop's 30s RPC limit (#125683).
The probe must scope ``GIT_NO_LAZY_FETCH`` to itself so missing objects fail fast into the
worktree-mtime fallback — never into network work — while a full checkout keeps resolving its time.
This clone is never checked out, so there is no mtime to fall back to either: ``None`` all the way."""
import shutil
import subprocess as sp
if shutil.which("git") is None:
pytest.skip("git not available")
src = tmp_path / "src"
(src / "plugin-catalog").mkdir(parents=True)
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
def local_object_types(repo):
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
capture_output=True, text=True, env=env)
return {line.split()[1] for line in out.stdout.splitlines() if line}
dst = tmp_path / "treeless"
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
check=True, env=env)
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
pytest.skip("file:// transport did not honor --filter=tree:0")
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh in this test
assert pc.in_tree_catalog_time() is None
assert "tree" not in local_object_types(dst) # the probe lazy-fetched nothing
monkeypatch.setattr(pc, "get_catalog_dir", lambda: src / "plugin-catalog")
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
assert pc.in_tree_catalog_time() is not None # a full checkout still resolves its commit time
def test_treeless_checkout_still_outranks_a_doc_fetched_before_the_bump(tmp_path, monkeypatch):
"""On the treeless layout the history probe fails fast, and ``None`` would feed the frozen-copy
rule that lets the live doc win — re-pinning the old sha on exactly the checkouts ``hermes
update`` just bumped. The checked-out files' mtime must stand in for the unreadable commit time
(fresh checkout ⇒ in-tree pin wins; catalog files older than the doc ⇒ live pin wins), with no
tree lazy-fetch anywhere (#125716 review)."""
import shutil
import subprocess as sp
if shutil.which("git") is None:
pytest.skip("git not available")
src = tmp_path / "src"
(src / "plugin-catalog").mkdir(parents=True)
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
(src / "plugin-catalog" / "a.yaml").write_text("name: a2\n")
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
sp.run(["git", "commit", "-qm", "bump"], cwd=src, check=True, env=env)
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
def local_object_types(repo):
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
capture_output=True, text=True, env=env)
return {line.split()[1] for line in out.stdout.splitlines() if line}
dst = tmp_path / "treeless"
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
check=True, env=env)
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
pytest.skip("file:// transport did not honor --filter=tree:0")
# Checking out only fetches HEAD's trees; the seed commit's tree stays missing, so the
# pathspec'd probe keeps failing — while the worktree files (and their mtimes) land.
sp.run(["git", "-C", str(dst), "checkout", "-q"], check=True, env=env)
probe = sp.run(["git", "-C", str(dst), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
capture_output=True, env={**env, "GIT_NO_LAZY_FETCH": "1"})
assert probe.returncode != 0 # the probe really is blind on this layout, like a real update
old, new = SHA, "a" * 40
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
"entries": [_entry("shared", sha=old)], "removed": []})
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh: probe fails, mtime stands in
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == new
# Control: catalog files older than the doc mean the checkout is the stale side — live pin wins.
for p in (dst / "plugin-catalog").rglob("*"):
os.utime(p, (0, 0))
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
+2 -1
View File
@@ -358,7 +358,8 @@ def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_p
target, _manifest, _name = _install_plugin_core(
repo.as_uri(), force=False, ref=old_sha
)
shutil.rmtree(target)
from hermes_cli.plugins_cmd import _remove_plugin_tree
_remove_plugin_tree(target)
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
@@ -0,0 +1,578 @@
"""Protected instruction-file gate when no human can be reached.
A one-shot run (``chat -q`` / ``--query-file``) installs the CLI approval
callback but never starts the prompt UI. The gate used to treat the
registered callback as a reachable human and wait out ``approvals.timeout``
on a panel nobody could see. These tests pin the fixed contract:
* no reachable human -> BLOCKED at once, nothing written, the outcome is
recorded for the launching process;
* the gate itself is unchanged: still asks a real human every time, still
ignores yolo and ``approvals.single_query_mode``;
* an approval that arrives after the calling execute_code script stopped
waiting is not applied.
"""
import json
import time
import pytest
@pytest.fixture(autouse=True)
def _gate_on(monkeypatch):
import tools.approval as A
import tools.file_tools as ft
monkeypatch.setattr(ft, "_protected_instruction_config", lambda: (True, []))
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
A.drain_human_approval_required()
A.set_approval_reply_deadline(None)
yield
A.drain_human_approval_required()
A.set_approval_reply_deadline(None)
@pytest.fixture
def approvals():
"""CLI approval callback that records calls and gives a scripted answer."""
from tools.terminal_tool import set_approval_callback
state = {"calls": [], "answer": "deny"}
def cb(command, description, **kwargs):
state["calls"].append({"command": command, **kwargs})
answer = state["answer"]
if callable(answer):
return answer()
return answer
set_approval_callback(cb)
yield state
set_approval_callback(None)
@pytest.fixture
def one_shot(monkeypatch):
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
def _write(path, content="injected"):
from tools.file_tools import write_file_tool
return json.loads(write_file_tool(str(path), content))
def _records():
import tools.approval as A
return A.drain_human_approval_required()
# ---- one-shot run: callback registered, nobody to answer ------------------
class TestOneShotRun:
def test_blocks_at_once_without_prompting(self, tmp_path, approvals, one_shot):
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
approvals["answer"] = "once" # would be honored if anyone were asked
started = time.monotonic()
res = _write(target)
elapsed = time.monotonic() - started
assert res.get("error") and "BLOCKED" in res["error"]
assert "APPROVAL REQUIRED" in res["error"]
assert target.read_text(encoding="utf-8") == "rules\n"
assert approvals["calls"] == [], "prompted on a surface nobody can see"
assert elapsed < 5
def test_outcome_is_recorded_for_the_launcher(self, tmp_path, approvals, one_shot):
import tools.approval as A
target = tmp_path / "CLAUDE.md"
sid = A._approval_session_id.set("sess-123")
tcid = A._approval_tool_call_id.set("call-9")
try:
_write(target)
finally:
A._approval_tool_call_id.reset(tcid)
A._approval_session_id.reset(sid)
records = _records()
assert len(records) == 1
rec = records[0]
assert rec["kind"] == "protected_instruction_file"
assert rec["reason"] == "no_interactive_user"
assert rec["applied"] is False
assert rec["session_id"] == "sess-123"
assert rec["tool_call_id"] == "call-9"
assert rec["request_id"]
assert any("CLAUDE.md" in t for t in rec["targets"])
assert _records() == [], "drain must clear"
def test_yolo_does_not_open_the_gate(self, tmp_path, approvals, one_shot, monkeypatch):
import tools.approval as A
monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", True)
target = tmp_path / "AGENTS.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
def test_single_query_mode_approve_does_not_open_the_gate(
self, tmp_path, approvals, one_shot, monkeypatch
):
import tools.approval as A
real_cfg_get = A.cfg_get
def fake_cfg_get(config, *keys, default=None):
if keys == ("approvals", "single_query_mode"):
return "approve"
return real_cfg_get(config, *keys, default=default)
monkeypatch.setattr(A, "cfg_get", fake_cfg_get)
target = tmp_path / "SOUL.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
def test_regular_file_still_writes(self, tmp_path, approvals, one_shot):
target = tmp_path / "notes.md"
res = _write(target, "hello")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "hello"
assert _records() == []
def test_multi_file_patch_applies_nothing(self, tmp_path, approvals, one_shot):
from tools.file_tools import patch_tool
agents = tmp_path / "AGENTS.md"
agents.write_text("rules\n", encoding="utf-8")
plain = tmp_path / "plain.txt"
plain.write_text("hello\n", encoding="utf-8")
patch = (
"*** Begin Patch\n"
f"*** Update File: {plain}\n"
"@@\n"
"-hello\n"
"+world\n"
f"*** Update File: {agents}\n"
"@@\n"
"-rules\n"
"+injected\n"
"*** End Patch"
)
res = json.loads(patch_tool(mode="patch", patch=patch))
assert res.get("error") and "BLOCKED" in res["error"]
assert plain.read_text(encoding="utf-8") == "hello\n"
assert agents.read_text(encoding="utf-8") == "rules\n"
assert approvals["calls"] == []
def test_retry_in_same_run_blocks_again(self, tmp_path, approvals, one_shot):
target = tmp_path / "AGENTS.md"
_write(target)
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert len(_records()) == 2
# ---- no channel at all ----------------------------------------------------
def test_no_channel_fails_closed_and_is_recorded(tmp_path):
target = tmp_path / "AGENTS.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
records = _records()
assert [r["reason"] for r in records] == ["no_approval_channel"]
def test_undeliverable_gateway_request_is_recorded(tmp_path):
import tools.approval as A
session_key = "protected-unattended-undelivered"
token = A.set_current_session_key(session_key)
try:
def notify(approval_data):
raise RuntimeError("client went away")
A.register_gateway_notify(session_key, notify)
try:
target = tmp_path / "AGENTS.md"
res = _write(target)
finally:
A.unregister_gateway_notify(session_key)
finally:
A.reset_current_session_key(token)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert [r["reason"] for r in _records()] == ["approval_not_delivered"]
# ---- a reachable human: the gate behaves exactly as before -----------------
class TestReachableHuman:
def test_allow_writes(self, tmp_path, approvals):
target = tmp_path / "AGENTS.md"
approvals["answer"] = "once"
res = _write(target, "approved")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "approved"
assert len(approvals["calls"]) == 1
assert _records() == []
@pytest.mark.parametrize("answer", ["deny", "timeout"])
def test_deny_and_silence_block(self, tmp_path, approvals, answer):
target = tmp_path / "AGENTS.md"
approvals["answer"] = answer
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert len(approvals["calls"]) == 1
# A human was asked and did not consent: not an "unattended" outcome.
assert _records() == []
def test_prompt_that_dies_blocks(self, tmp_path, approvals):
"""Cancel / lost surface: the prompt raising is not consent."""
def boom():
raise RuntimeError("prompt surface lost")
target = tmp_path / "AGENTS.md"
approvals["answer"] = boom
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
# ---- approval that arrives after the caller stopped waiting ----------------
class TestLateApproval:
def test_late_cli_approval_is_not_applied(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
def answer_after_deadline():
# The human says yes, but only once the execute_code script that
# asked for this write has already timed out.
A.set_approval_reply_deadline(time.monotonic() - 1)
return "once"
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = answer_after_deadline
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert "NOT applied" in res["error"]
assert target.read_text(encoding="utf-8") == "rules\n"
def test_late_approval_does_not_carry_to_the_next_request(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
A.set_approval_reply_deadline(time.monotonic() - 1)
approvals["answer"] = "once"
assert "BLOCKED" in _write(target).get("error", "")
assert not target.exists()
# A fresh call (new deadline) asks again; the old yes is not reused.
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = "deny"
assert "BLOCKED" in _write(target).get("error", "")
assert not target.exists()
assert len(approvals["calls"]) == 2
def test_in_time_approval_is_applied(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = "once"
res = _write(target, "approved")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "approved"
def test_late_gateway_approval_is_not_applied(self, tmp_path):
import tools.approval as A
session_key = "protected-unattended-late"
token = A.set_current_session_key(session_key)
try:
def notify(approval_data):
A.resolve_gateway_approval(session_key, "once")
A.register_gateway_notify(session_key, notify)
try:
A.set_approval_reply_deadline(time.monotonic() - 1)
target = tmp_path / "AGENTS.md"
res = _write(target)
finally:
A.unregister_gateway_notify(session_key)
finally:
A.reset_current_session_key(token)
assert res.get("error") and "NOT applied" in res["error"]
assert not target.exists()
def test_no_deadline_outside_execute_code(self):
import tools.approval as A
assert A.approval_reply_window_remaining() is None
assert A.approval_arrived_too_late() is False
def test_rpc_dispatch_binds_and_restores_the_deadline(self):
import tools.approval as A
import tools.code_execution_tool as cet
start = time.monotonic()
previous = cet._bind_rpc_reply_deadline(start)
try:
remaining = A.approval_reply_window_remaining()
assert remaining is not None
assert 0 < remaining <= cet._RPC_REPLY_TIMEOUT_SECONDS
finally:
cet._restore_rpc_reply_deadline(previous)
assert A.approval_reply_window_remaining() is None
def test_deadline_is_per_thread(self):
import threading
import tools.approval as A
A.set_approval_reply_deadline(time.monotonic() - 1)
seen = {}
def other():
seen["late"] = A.approval_arrived_too_late()
worker = threading.Thread(target=other)
worker.start()
worker.join()
assert seen["late"] is False
assert A.approval_arrived_too_late() is True
# ---- what the launching process sees ---------------------------------------
def test_recorded_outcomes_are_bounded():
import tools.approval as A
for i in range(A._HUMAN_APPROVAL_REQUIRED_MAX + 20):
A.note_human_approval_required("protected_instruction_file", [f"f{i}"], "x")
records = A.drain_human_approval_required()
assert len(records) == A._HUMAN_APPROVAL_REQUIRED_MAX
assert records[-1]["targets"] == [f"f{A._HUMAN_APPROVAL_REQUIRED_MAX + 19}"]
def test_one_shot_exit_reports_approval_required_on_stderr(capsys):
import cli as cli_module
import tools.approval as A
A.note_human_approval_required(
"protected_instruction_file", ["AGENTS.md"], "no_interactive_user"
)
cli_module._emit_approval_required("sess-42")
err = capsys.readouterr().err
lines = [l for l in err.splitlines() if l.startswith("approval_required: ")]
assert len(lines) == 1
payload = json.loads(lines[0].split(": ", 1)[1])
assert payload["session_id"] == "sess-42"
assert payload["applied"] is False
assert payload["reason"] == "no_interactive_user"
assert payload["resume"] == "interactive"
assert payload["targets"] == ["AGENTS.md"]
cli_module._emit_approval_required("sess-42")
assert "approval_required" not in capsys.readouterr().err
# ---- execute_code RPC: the dispatcher really binds the window ---------------
def _drive_rpc_once(dispatch, stop_event=None):
"""Send one tool call through the real RPC server loop; return its reply."""
import socket
import threading
import tools.code_execution_tool as cet
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
stop_event = stop_event or threading.Event()
worker = threading.Thread(
target=cet._rpc_server_loop,
args=(server, "task", [], [0], 5, frozenset({"patch"}), stop_event, "tok", dispatch),
daemon=True,
)
worker.start()
client = socket.create_connection(server.getsockname(), timeout=10)
try:
client.sendall(
(json.dumps({"tool": "patch", "args": {}, "token": "tok"}) + "\n").encode()
)
buf = b""
while not buf.endswith(b"\n"):
chunk = client.recv(65536)
if not chunk:
break
buf += chunk
finally:
client.close()
stop_event.set()
worker.join(timeout=10)
server.close()
return buf.decode().strip()
def test_rpc_server_loop_binds_reply_window_around_dispatch():
import tools.approval as A
import tools.code_execution_tool as cet
seen = {}
def dispatch(tool_name, tool_args):
seen["remaining"] = A.approval_reply_window_remaining()
seen["late"] = A.approval_arrived_too_late()
return json.dumps({"ok": True})
reply = _drive_rpc_once(dispatch)
assert json.loads(reply) == {"ok": True}
assert seen["late"] is False
assert 0 < seen["remaining"] <= cet._RPC_REPLY_TIMEOUT_SECONDS
def test_rpc_approval_after_script_ended_is_late():
"""Script killed by its own run timeout while the prompt is still open."""
import threading
import tools.approval as A
stop_event = threading.Event()
seen = {}
def dispatch(tool_name, tool_args):
seen["before"] = A.approval_arrived_too_late()
stop_event.set() # the script ended while a human was deciding
seen["after"] = A.approval_arrived_too_late()
return json.dumps({"ok": True})
_drive_rpc_once(dispatch, stop_event)
assert seen == {"before": False, "after": True}
def test_protected_write_through_rpc_is_dropped_when_script_ended(tmp_path, approvals):
"""End to end through the RPC loop: late yes -> BLOCKED, file untouched."""
import threading
from tools.file_tools import write_file_tool
from tools.terminal_tool import _get_approval_callback, set_approval_callback
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
stop_event = threading.Event()
callback = _get_approval_callback()
def answer_after_script_died():
stop_event.set()
return "once"
approvals["answer"] = answer_after_script_died
def dispatch(tool_name, tool_args):
set_approval_callback(callback) # the RPC thread carries the cell's callback
try:
return write_file_tool(str(target), "injected")
finally:
set_approval_callback(None)
reply = json.loads(_drive_rpc_once(dispatch, stop_event))
assert "NOT applied" in reply.get("error", "")
assert target.read_text(encoding="utf-8") == "rules\n"
assert len(approvals["calls"]) == 1
def test_retired_kernel_cell_counts_as_gone():
import tools.approval as A
cell = {"active": True}
previous = A.also_gone_when(lambda: not cell["active"])
try:
assert A.approval_arrived_too_late() is False
cell["active"] = False
assert A.approval_arrived_too_late() is True
finally:
A.restore_approval_reply_deadline(previous)
assert A.approval_arrived_too_late() is False
# ---- an automatic approver is not a person ----------------------------------
def test_subagent_auto_approve_cannot_open_the_gate(tmp_path):
from tools.delegate_tool import _subagent_auto_approve
from tools.terminal_tool import set_approval_callback
target = tmp_path / "AGENTS.md"
set_approval_callback(_subagent_auto_approve)
try:
res = _write(target)
finally:
set_approval_callback(None)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert [r["reason"] for r in _records()] == ["no_interactive_user"]
def test_blocked_message_survives_braces_in_target(tmp_path):
import tools.file_tools as ft
res = ft._request_protected_instruction_approval(["{be}/AGENTS.md"])
assert res.startswith("BLOCKED") and "{be}/AGENTS.md" in res
# ---- sibling gates in a one-shot run ----------------------------------------
def test_computer_use_does_not_wait_in_one_shot_run(one_shot, monkeypatch):
import tools.computer_use.tool as cu
calls = []
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
res = cu._request_approval("click", {}, session_id="unattended-test")
assert res is not None and "one-shot run" in json.loads(res)["error"]
assert calls == []
def test_computer_use_still_asks_when_interactive(monkeypatch):
import tools.computer_use.tool as cu
calls = []
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
assert cu._request_approval("click", {}, session_id="attended-test") is None
assert len(calls) == 1
def test_memory_write_prompt_unavailable_in_one_shot_run(approvals, monkeypatch):
import tools.write_approval as wa
assert wa._interactive_approval_available() is True
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
assert wa._interactive_approval_available() is False
def test_one_shot_marker_is_not_inherited_by_child_processes(one_shot):
from tools.environments.local import _make_run_env
assert "HERMES_SINGLE_QUERY_SESSION" not in _make_run_env({})
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from tui_gateway import mobile_subagents as mobile
def test_roster_is_session_and_transport_scoped(monkeypatch):
from tools import delegate_tool as delegate
transport, session = object(), {}
own = dict(subagent_id="own", owner_session_id="s", owner_transport=transport,
owner_session_record=session, agent=object(), goal="test", started_at=1)
other = {**own, "subagent_id": "other", "owner_transport": object()}
reused = {**own, "subagent_id": "reused", "owner_session_record": {}}
monkeypatch.setattr(delegate, "_active_subagents", {"own": own, "other": other, "reused": reused})
rows = mobile.owned_records("s", transport, session)
assert [row["subagent_id"] for row in rows] == ["own"]
assert mobile.owned_records("s", None, session) == []
assert mobile.owned_records("wrong", transport, session) == []
public = mobile.roster(rows)["subagents"][0]
assert public["elapsed_seconds"] > 0
assert "agent" not in public and "owner_session_record" not in public
def test_tail_is_bounded_and_unknown_child_is_unavailable(tmp_path, monkeypatch):
from tools import delegation_live_log
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path)
path = tmp_path / "transcript.jsonl"
path.write_bytes(b"x" * 20000 + b"end")
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(path)))]
result = mobile.transcript_tail(rows, "own")
assert result["available"] and result["truncated"]
assert result["bytes_read"] == 16384 and result["text"].endswith("end")
assert not mobile.transcript_tail(rows, "other")["available"]
path.unlink()
assert not mobile.transcript_tail(rows, "own")["available"]
def test_tail_rejects_outside_log_root(tmp_path, monkeypatch):
from tools import delegation_live_log
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path / "logs")
secret = tmp_path / "private.txt"
secret.write_text("not a transcript")
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(secret)))]
assert not mobile.transcript_tail(rows, "own")["available"]
def test_roster_survives_resume_but_tail_keeps_exact_authority(monkeypatch):
from tools import delegate_tool as delegate
parent = SimpleNamespace(session_id="conversation")
record = dict(subagent_id="child", owner_session_id="old-runtime",
owner_agent_session_id="conversation", agent=None)
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
session = {"agent": parent}
assert mobile.owned_records("new-runtime", object(), session) == []
assert len(mobile.owned_records("new-runtime", object(), session, include_lineage=True)) == 1
def test_registered_rpcs_reject_wrong_transport(monkeypatch):
from tui_gateway import server
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: ({"session_key": "s"}, None))
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (None, None))
for name in ("subagent.list", "subagent.tail", "subagent.interrupt"):
response = server._methods[name](1, {"session_id": "s", "subagent_id": "child"})
assert response["error"]["code"] == 4001
def test_registered_list_returns_owned_children(monkeypatch):
from tui_gateway import server
from tools import delegate_tool as delegate
transport, session = object(), {"session_key": "s"}
record = dict(subagent_id="child", owner_session_id="s", owner_transport=transport,
owner_session_record=session, goal="test")
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None))
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (transport, session))
response = server._methods["subagent.list"](1, {"session_id": "s"})
assert response["result"]["subagents"][0]["subagent_id"] == "child"
+109
View File
@@ -78,6 +78,115 @@ _hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.Con
)
# Absolute ``time.monotonic()`` instant after which whoever issued the
# current tool call has stopped waiting for its result. Set by the
# execute_code RPC dispatchers, whose sandbox client gives up after a fixed
# reply timeout. An approval that resolves after this instant must NOT be
# acted on: the caller already reported the call as failed, so applying it
# would be a write the agent believes never happened (and may redo).
# The reply timeout is not the only way a caller goes away: the script can
# hit its own run timeout or be interrupted first. ``gone`` is a probe the
# dispatcher supplies for that ("has my script/cell already ended?").
# Thread-local on purpose: the dispatcher and the tool handler it calls run
# on the same thread, whatever context the handler is re-bound to.
_approval_reply_deadline = threading.local()
def set_approval_reply_deadline(deadline: Optional[float], gone=None):
"""Bind the caller's reply window for this thread; return the prior binding.
``deadline`` is monotonic seconds (None = unbounded); ``gone`` is an
optional zero-argument callable that turns true once the caller has
ended. Pass the returned value to :func:`restore_approval_reply_deadline`.
"""
previous = (
getattr(_approval_reply_deadline, "value", None),
getattr(_approval_reply_deadline, "gone", None),
)
_approval_reply_deadline.value = deadline
_approval_reply_deadline.gone = gone
return previous
def restore_approval_reply_deadline(previous) -> None:
"""Undo :func:`set_approval_reply_deadline` / :func:`also_gone_when`."""
deadline, gone = previous if previous else (None, None)
_approval_reply_deadline.value = deadline
_approval_reply_deadline.gone = gone
def also_gone_when(probe):
"""Add another "caller has ended" probe, keeping the current window."""
deadline = getattr(_approval_reply_deadline, "value", None)
current = getattr(_approval_reply_deadline, "gone", None)
def gone() -> bool:
return bool(probe()) or bool(current and current())
return set_approval_reply_deadline(deadline, gone)
def approval_reply_window_remaining() -> Optional[float]:
"""Seconds left before the caller stops waiting, or None when unbounded."""
deadline = getattr(_approval_reply_deadline, "value", None)
if deadline is None:
return None
return deadline - time.monotonic()
def approval_arrived_too_late() -> bool:
"""True when the caller of the current tool call has stopped waiting."""
gone = getattr(_approval_reply_deadline, "gone", None)
if gone is not None:
try:
if gone():
return True
except Exception:
pass
remaining = approval_reply_window_remaining()
return remaining is not None and remaining <= 0
# Approvals that could not be put in front of a human because the run has
# no interactive channel (single-query ``chat -Q`` stage runs, scripts).
# The single-query CLI reports them on stderr as ``approval_required:``
# lines so the coordinating process gets a machine-readable outcome
# instead of inferring one from prose. Bounded: long-lived processes that
# never drain it must not grow without limit.
_human_approval_required: list[dict] = []
_HUMAN_APPROVAL_REQUIRED_MAX = 50
_human_approval_required_lock = threading.Lock()
def note_human_approval_required(kind: str, targets: list[str], reason: str) -> dict:
"""Record an approval the run could not present to a human.
Captures the active session/tool-call identity so the coordinator can
tie the outcome to the exact job and resume the same session.
"""
record = {
"kind": kind,
"targets": list(dict.fromkeys(str(t) for t in targets)),
"reason": reason,
"request_id": uuid.uuid4().hex,
"tool_call_id": _approval_tool_call_id.get() or "",
"session_id": _approval_session_id.get() or os.getenv("HERMES_SESSION_ID", ""),
"applied": False,
}
with _human_approval_required_lock:
_human_approval_required.append(record)
del _human_approval_required[:-_HUMAN_APPROVAL_REQUIRED_MAX]
return dict(record)
def drain_human_approval_required() -> list[dict]:
"""Return and clear the recorded approval-required outcomes."""
with _human_approval_required_lock:
records = list(_human_approval_required)
_human_approval_required.clear()
return records
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
"""Bind interactive mode for the current context (thread or asyncio task).
+39
View File
@@ -716,6 +716,36 @@ def _call(tool_name, args):
# RPC server (runs in a thread inside the parent process)
# ---------------------------------------------------------------------------
# How long the generated sandbox client waits for one tool-call reply
# (``_sock.settimeout(300)`` / the file transport's 300s poll deadline).
_RPC_REPLY_TIMEOUT_SECONDS = 300
def _bind_rpc_reply_deadline(call_start: float, stop_event=None):
"""Tell the approval gates when this call's requester stops waiting.
The requester is gone at the reply timeout, or earlier when the script
itself ended (run timeout, interrupt): ``stop_event`` is set then.
"""
try:
from tools.approval import set_approval_reply_deadline
gone = stop_event.is_set if stop_event is not None else None
return (True, set_approval_reply_deadline(
call_start + _RPC_REPLY_TIMEOUT_SECONDS, gone))
except Exception:
return (False, None)
def _restore_rpc_reply_deadline(previous) -> None:
if not previous or not previous[0]:
return
try:
from tools.approval import restore_approval_reply_deadline
restore_approval_reply_deadline(previous[1])
except Exception:
pass
# Terminal parameters that must not be used from ephemeral sandbox scripts
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
@@ -826,12 +856,18 @@ def _rpc_server_loop(
# Dispatch through the standard tool handler.
# Suppress stdout/stderr from internal tool handlers so
# their status prints don't leak into the CLI spinner.
# The sandbox client stops waiting for this reply after
# _RPC_REPLY_TIMEOUT_SECONDS; an approval granted later
# must not apply a write the script already saw fail.
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
try:
with thread_scoped_silence():
result = dispatch(tool_name, tool_args)
except Exception as exc:
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
result = tool_error(str(exc))
finally:
_restore_rpc_reply_deadline(_prev_deadline)
tool_call_counter[0] += 1
call_duration = time.monotonic() - call_start
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
tool_args.pop(param, None)
# Dispatch through the standard tool handler
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
try:
with thread_scoped_silence():
tool_result = handle_function_call(
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
logger.error("Tool call failed in remote sandbox: %s",
exc, exc_info=True)
tool_result = tool_error(str(exc))
finally:
_restore_rpc_reply_deadline(_prev_deadline)
tool_call_counter[0] += 1
call_duration = time.monotonic() - call_start
+16
View File
@@ -322,9 +322,25 @@ class CellAuthority:
set_sudo(self._sudo_cb)
except Exception:
previous = None
# A cell that settled (timeout, interrupt, error) no longer waits for
# this call: an approval granted afterwards must not be applied.
reply_window = None
try:
from tools.approval import also_gone_when
reply_window = (also_gone_when(lambda: not self.active),)
except Exception:
reply_window = None
try:
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
finally:
if reply_window is not None:
try:
from tools.approval import restore_approval_reply_deadline
restore_approval_reply_deadline(reply_window[0])
except Exception:
pass
if previous is not None and self._callback_setters is not None:
set_approval, set_sudo = self._callback_setters
try:
+16
View File
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
# No CLI approval wired — default allow. Gateway approval is handled
# one layer out via the normal tool-approval infra.
return None
try:
from tools.approval import _is_single_query_approval_context
unattended = _is_single_query_approval_context()
except Exception:
unattended = False
if unattended:
# One-shot run: the prompt callback is installed but its panel is
# never shown, so asking would only wait out approvals.timeout.
return json.dumps({
"error": (
"approval required but this is a one-shot run with no "
"interactive user to approve it. Silence is not consent; "
"do not retry without the user."
),
"action": action,
})
summary = _summarize_action(action, args)
try:
verdict = cb(action, args, summary)
+5
View File
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
return "once"
# Not a person: gates that require real human consent every time (protected
# instruction files) must not accept this callback's answer.
_subagent_auto_approve.unattended = True
def _get_subagent_approval_callback():
"""Return the callback to install into subagent worker threads.
+4
View File
@@ -1546,6 +1546,10 @@ def _make_run_env(env: dict) -> dict:
run_env[real_key] = v
elif _is_hermes_internal_secret(k):
continue
elif k == "HERMES_SINGLE_QUERY_SESSION":
# Describes this process only. A child (an interactive hermes
# started from a one-shot run's terminal) sets its own.
continue
else:
first_party = _is_terminal_first_party_env(k)
passthrough = _is_passthrough(k)
+79 -14
View File
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
"required (not bypassed by auto-approve)."
)
display = f"<write to {targets}>"
blocked = (
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
"{why} The user has NOT consented to this write. Do NOT retry it or "
"attempt the same edit via another path (terminal, execute_code, "
"etc.)."
)
class _Blocked:
# Not str.format: a target path may itself contain braces.
@staticmethod
def format(why: str) -> str:
return (
"BLOCKED: write to protected agent-instruction file(s) "
f"({targets}) {why} The user has NOT consented to this "
"write. Do NOT retry it or attempt the same edit via "
"another path (terminal, execute_code, etc.)."
)
blocked = _Blocked
try:
import tools.approval as _approval
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
return blocked.format(why="requires approval but the approval "
"subsystem is unavailable.")
def _needs_human(reason: str, why: str) -> str:
# No human could be shown this request. Record it so a one-shot
# run can hand its coordinator a machine-readable outcome, and tell
# the agent where a human CAN approve it instead of waiting on a
# prompt nobody sees.
try:
_approval.note_human_approval_required(
"protected_instruction_file", list(reasons), reason)
except Exception:
pass
return blocked.format(
why=why + " APPROVAL REQUIRED: a human must approve this from "
"an interactive session (resume this same session in "
"the desktop app or an interactive terminal and "
"request the write again there).")
def _too_late() -> str | None:
# The approval came back after whoever issued this call stopped
# waiting (execute_code RPC reply timeout). The caller already saw
# a failure, so applying the write now would be an edit the agent
# believes never happened.
try:
late = _approval.approval_arrived_too_late()
except Exception:
late = False
if not late:
return None
logger.warning(
"Approval for protected instruction file(s) %s arrived after "
"the caller stopped waiting; write not applied.", targets)
return blocked.format(
why="was approved only after the calling script had stopped "
"waiting for the result, so the write was NOT applied. "
"The approval is spent; a new request needs a new approval.")
# Gateway surface: block on the button round-trip when a notify callback
# is registered for this session (Telegram/Discord/Slack). One-operation
# only — no session/permanent buttons are offered.
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
session_key, notify_cb, approval_data, surface="gateway",
)
if decision.get("notify_failed"):
return blocked.format(
why="requires approval but the approval request could not "
"be delivered.")
return _needs_human(
"approval_not_delivered",
"requires approval but the approval request could not "
"be delivered.")
choice = decision.get("choice")
if decision.get("resolved") and choice in {"once", "session", "always"}:
# One-operation grant regardless of the tapped scope — nothing
# is persisted for this gate.
return None
return _too_late()
if not decision.get("resolved"):
return blocked.format(
why="approval prompt timed out without a user response. "
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
callback = None
if callback is not None:
# A one-shot run (``chat -q`` / ``--query-file``) installs the
# prompt callback but never starts the prompt UI: the panel would
# be painted nowhere and the wait would last the whole
# approvals.timeout with nobody able to answer. A registered
# callback is not a reachable human. Fail closed NOW;
# approvals.single_query_mode does not apply to this gate.
try:
unattended = _approval._is_single_query_approval_context()
except Exception:
unattended = False
if unattended:
return _needs_human(
"no_interactive_user",
"requires approval but this is a one-shot run with no "
"interactive user to approve it.")
if getattr(callback, "unattended", False):
# An auto-answering callback (subagent auto-approve) is not a
# person; this gate takes consent from a human only.
return _needs_human(
"no_interactive_user",
"requires approval but only an automatic approver is "
"attached to this run, not a person.")
choice = _approval.prompt_dangerous_approval(
display, description,
allow_permanent=False,
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
)
if choice in {"once", "session", "always"}:
# One-operation grant; never persisted (see docstring).
return None
return _too_late()
if choice == "timeout":
return blocked.format(
why="approval prompt timed out without a user response. "
@@ -938,9 +1002,10 @@ def _request_protected_instruction_approval(
# No human channel at all (script, cron, background thread): fail
# closed. Auto-approving here would recreate the persistence vector.
return blocked.format(
why="requires approval but no interactive user or gateway is "
"present to approve it.")
return _needs_human(
"no_approval_channel",
"requires approval but no interactive user or gateway is "
"present to approve it.")
def _check_protected_instruction_write(paths: list[str],
+8
View File
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
review affordance (``/memory pending``) instead.
* Scripts, cron, and background threads — no user present.
"""
try:
from tools.approval import _is_single_query_approval_context
if _is_single_query_approval_context():
# One-shot run: the callback exists but its panel is never
# shown, so an inline prompt would wait with nobody to answer.
return False
except Exception:
pass
try:
from tools.terminal_tool import _get_approval_callback
return _get_approval_callback() is not None
+41 -2
View File
@@ -3572,14 +3572,53 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"paused": set_spawn_paused(paused)})
@method("subagent.list")
def _(rid, params: dict) -> dict:
from tui_gateway.mobile_subagents import owned_records, roster
_, err = _sess_nowait(params, rid)
if err:
return err
session_id = str(params.get("session_id") or "").strip()
transport, session = _current_session_steer_authority(session_id)
if transport is None or session is None:
return _err(rid, 4001, "session not found or not owned by this transport")
return _ok(rid, roster(owned_records(session_id, transport, session, include_lineage=True)))
@method("subagent.tail")
def _(rid, params: dict) -> dict:
from tui_gateway.mobile_subagents import owned_records, transcript_tail
_, err = _sess_nowait(params, rid)
if err:
return err
subagent_id = str(params.get("subagent_id") or "").strip()
if not subagent_id:
return _err(rid, 4000, "subagent_id required")
session_id = str(params.get("session_id") or "").strip()
transport, session = _current_session_steer_authority(session_id)
if transport is None or session is None:
return _err(rid, 4001, "session not found or not owned by this transport")
with _session_home_scope(session):
result = transcript_tail(owned_records(session_id, transport, session), subagent_id)
return _ok(rid, result)
@method("subagent.interrupt")
def _(rid, params: dict) -> dict:
from tools.delegate_tool import interrupt_subagent
from tui_gateway.mobile_subagents import owned_records
from agent.interrupt_compat import request_hard_interrupt
subagent_id = str(params.get("subagent_id") or "").strip()
if not subagent_id:
return _err(rid, 4000, "subagent_id required")
ok = interrupt_subagent(subagent_id)
session_id = str(params.get("session_id") or "").strip()
transport, session = _current_session_steer_authority(session_id)
if transport is None or session is None:
return _err(rid, 4001, "session not found or not owned by this transport")
record = next((r for r in owned_records(session_id, transport, session)
if r.get("subagent_id") == subagent_id), None)
child = record.get("agent") if record else None
ok = bool(request_hard_interrupt(child, "Interrupted via mobile")) if child is not None else False
return _ok(rid, {"found": ok, "subagent_id": subagent_id})
+55
View File
@@ -0,0 +1,55 @@
"""Read-only mobile subagent views, scoped to the invoking live session."""
import time
from pathlib import Path
def owned_records(session_id, transport, session, *, include_lineage=False):
from tools.delegate_tool import _active_subagents, _active_subagents_lock, _owns_subagent_record
if transport is None or session is None:
return []
with _active_subagents_lock:
records = [dict(row) for row in _active_subagents.values()]
return [row for row in records
if (row.get("owner_session_id") == session_id
and row.get("owner_transport") is transport
and row.get("owner_session_record") is session)
or (include_lineage and _owns_subagent_record(row, session.get("agent")))]
def roster(records):
fields = ("subagent_id", "parent_id", "depth", "goal", "delegation_id", "model",
"started_at", "status", "tool_count", "last_tool", "accepting_steer")
rows = []
for record in records:
row = {key: record[key] for key in fields if key in record}
started = record.get("started_at")
if isinstance(started, (int, float)):
row["elapsed_seconds"] = max(0, time.time() - started)
rows.append(row)
return {"subagents": rows, "delegations": []}
def transcript_tail(records, subagent_id):
result = {"subagent_id": subagent_id, "available": False, "text": "", "bytes_read": 0, "truncated": False}
record = next((r for r in records if r.get("subagent_id") == subagent_id), None)
if record is None:
return result
path = getattr(record.get("agent"), "_live_transcript_path", None)
if not isinstance(path, (str, Path)):
return result
try:
# Only the registry's own file is read; no client-supplied path is accepted.
from tools.delegation_live_log import live_transcript_root
path = Path(path)
root = live_transcript_root().resolve()
if path.is_symlink() or not path.resolve().is_relative_to(root):
return result
with path.open("rb") as stream:
size = stream.seek(0, 2)
stream.seek(max(0, size - 16384))
raw = stream.read(16384)
result.update(available=True, text=raw.decode("utf-8", errors="replace"),
bytes_read=len(raw), truncated=size > len(raw))
except OSError:
pass # A just-completed child's temporary transcript may already be gone.
return result