diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f85e701..bfcb1be 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: "$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf - name: Codex bridge regression tests run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge' + - name: Native watch and speech mux pytest regressions + run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py -q -p no:cacheprovider -rf' - name: Mobile and desktop startup regression tests run: | npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1 @@ -54,6 +56,13 @@ jobs: github-server-url: https://gitea.turkservis.online - name: Install locked source dependencies run: npm ci --no-audit --no-fund + - name: Install Windows launcher cross compiler and framework reference + run: | + set -eu + apt-get update + apt-get install -y --no-install-recommends mono-mcs=6.8.0.105+dfsg-3.2 libmono-system-web-extensions4.0-cil=6.8.0.105+dfsg-3.2 + - name: Compile Windows launcher from source and verify failure gate + run: node --test apps/desktop/scripts/build-native-watch-launcher.test.mjs - name: Native dependency staging regression tests run: npx vitest run --root apps/desktop scripts/stage-native-deps.test.mjs --maxWorkers=2 - name: Build and package Windows x64 diff --git a/apps/desktop/assets/codex-bridge/.gitignore b/apps/desktop/assets/codex-bridge/.gitignore new file mode 100644 index 0000000..701f1a8 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/.gitignore @@ -0,0 +1,2 @@ +# Generated by beforePack from NativeWatchBootstrap.cs; never commit opaque output. +/native-watch.exe diff --git a/apps/desktop/assets/codex-bridge/NATIVE-WATCH-LAUNCHER.md b/apps/desktop/assets/codex-bridge/NATIVE-WATCH-LAUNCHER.md new file mode 100644 index 0000000..e48a6e4 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/NATIVE-WATCH-LAUNCHER.md @@ -0,0 +1,136 @@ +# Native watch launcher — reviewable Windows candidate + +This is an owned startup hook for the original Codex engine, not another model +engine. Do not activate it until root has built a new packaged release and +reviewed the evidence. No environment variable is set by build/provision tools. + +## Build and provision + +Normal Windows `beforePack` invokes `scripts/build-native-watch-launcher.mjs`. +The resulting `native-watch.exe` is included in the existing codex-bridge +extraResources directory along with Python modules. Windows hosts use the .NET Framework C# compiler. The existing Linux/Wine +Windows-package job installs mono-mcs and libmono-system-web-extensions4.0-cil +6.8.0.105+dfsg-3.2 from its Ubuntu 22.04 apt repository and compiles with mcs. +Both hosts rebuild the same C# source; missing compilers or invalid source fail +packaging and remove stale output. The generated EXE is ignored by git. +This is a Windows artifact cross-build, not a Linux runtime launcher. The executable is a managed console bootstrap; use +an absolute managed **python.exe**, not pythonw.exe. It checks pinned hashes of +original binary, interpreter and launcher script. Original is checked again in +Python immediately before use. Parent directory ACLs and the selected official +binary hash are provisioning authority; this does not claim Authenticode trust. + +After installing the new package, root provisions an owner-local candidate: + +```powershell +py -3 prepare_native_watch.py --source --destination --original --expected-sha256 --python --bridge-home --hermes-home +``` + +`prepare_native_watch.py` copies only the relevant resources and writes the +adjacent native-watch.json (paths/hashes only, no keys). It never reads +CODEX_CLI_PATH to locate the original and rejects its own executable as original. +Root alone may later point Desktop's CODEX_CLI_PATH at this EXE. No Python argv +are placed inside CODEX_CLI_PATH. No installed app.asar/binary modification. +Rollback is root removing its override to restore original app startup. + +Installed app read-only inspection (OpenAI.Codex_26.1007.2314.0): +`.vite/build/application-network-startup-CfAQmSYg.js` tc()/Ks() constructs +`-c` pairs before/after `app-server`, `--analytics-default-enabled`. Recognizer +accepts that grammar plus explicit stdio forms, rejects daemon/websocket/unknown +commands and passes those unchanged to the original. Approvals flow untouched. +One original engine only; no extra version process at startup. Original cwd and +args preserved except one per-child `-c openai_base_url` selecting the in-process +bridge's ephemeral loopback `/backend-api/codex`. Normal original models and +public member catalog reuse kopru.H. Native environment is restored after bridge +import. Bridge logging uses stderr so stdout remains native JSONL only. + +## Receipt, delivery and lifecycle + +Before routing/rewrite, all three actual request values must agree: +session-id, thread-id and prompt_cache_key. The active mux connection ID and +realtime generation are captured then. Neither current tab nor notice data can +create that mapping. thread/unsubscribe, realtime stop/start/closed/error and EOF +invalidate an epoch. UI-only switches not observable in native protocol cannot +be detected here; root must verify Desktop emits stop/unsubscribe for its switch. +Concurrent active native threads have independent request mappings. + +SSE `event: bekci.watch` and nonstream `_bekci_watch` are removed before native +model text, history observers, response rewriting and response preview logging. +Only `/v1/bekci/events` and the fixed `https://ai.turkservis.online` origin are +accepted. The same primary route public member key authenticates POST; redirects +are not followed. No backend keys, raw server tenant/session/history identifiers, +or reconstructed history exist in this client. + +Only authenticated SSE `ready` binds the receipt. Binding's legacy tenant_id, +bekci_session_id, history_revision fields contain **local opaque scopes**, not +literal server claims: member-key fingerprint, key+public-subscription digest, +public-receipt revision digest. Server remains authoritative for hidden history. +TLS plus same-key proxy ready is the receipt validity attestation. SQLite also +locks receipt scope to its original connection/thread/generation across restarts. + +SQLite WAL/FULL ledger commits `uncertain` before enqueue. Replays or a crash +between commit and append are suppressed, including possible lost delivery. +Cursor is persisted after durable admission/disposition; it is a local replay +ack, not a server consumption API or exactly-once guarantee. Native RPC acceptance +does not prove audio output. Replay IDs never cause automatic uncertain resend. +Keepalives, bounded reconnect attempts, receipt expiry, lifecycle abort and +simultaneous threads are handled. Receipt expiry invalidates its local binding. +No raw notice text or credentials are persisted in the ledger. + +## Evidence and exact remaining operations + +- `.claude/sesli-client-all-tests.log`: 20 files, 203 passed, 0 failed. +- `.claude/sesli-client-tests-red.log`: compiled Windows bootstrap lost stdout/ + stderr before fix; interactive flush subsequently exposed by real native probe + and covered by `test_executable_flushes_interactive_stdin_before_eof`. +- `.claude/native-launcher-proof.json`: compiled candidate, pinned official native + initialize accepted, stdout JSON, EOF exit 0. No native voice call performed. +- `.claude/native-launcher-candidate/native-watch.exe`: concrete provisioned + candidate under worktree, not activated or installed. + +Source now stamps desktop beta.30 and bridge 10. Root still must run +normal full package CI/build/signing policy, inspect packaged resources, provision the +new packaged candidate with trusted official SHA/managed runtime, activate only +authorized Desktop startup, verify thread-switch notifications and exercise a +real Sesli-Hizli-Zeki call through deployed server to audible appendSpeech. The bootstrap assigns itself to a non-inheritable KILL_ON_JOB_CLOSE Windows +Job Object before Python starts. Its descendants inherit membership; abrupt +bootstrap termination closes the handle and kills only that owned tree. Owned +fake and pinned original initialize/kill tests passed for both Windows-built and +Linux-built EXEs running on Windows. Actual Desktop UI termination remains a +manual acceptance step. Atomic generation enforcement inside native appendSpeech +is still unavailable (the native RPC has no expected-generation field). +No commit, push, deployment, installed runtime change or AITURK delegation. + +## 2026-10-12 source verification update + +- `.claude/native-watch-linux-build.log`: exact CI image digest + `sha256:b226b27b1a7e95ad25eb3d6414ea90c56c04eb6d8329967f4d721dce50b989ff`, + real apt dependencies, two compiler regressions passed, Windows PE produced. +- `.claude/native-watch-windows-build.log`: same two tests passed with Framework csc. +- `.claude/native-watch-linux-artifact-windows-tests.log`: 25 tests passed with + the cross-built artifact, including pinned original initialize and abrupt kill. +- `.claude/sesli-client-release-all-tests.log`: 220 passed, 21 files, zero failures. +- `.claude/electron-beta30-tests.log`: 47 passed including Python/Electron version alignment. + +Receipt validation accepts up to 24 hours plus 60 seconds clock skew. Actual +expiry remains authoritative; expired, boolean, nonnumeric, NaN, infinite and +over-bound receipts are rejected. Long research has no local 15-minute limit. + +Provision the exact rebuilt candidate with the explicit original SHA-256 +`d13914ced6c7af174d8d638db938284231227312104b06a25e9608c632a7daec` only +when that is the selected independently verified official original. SHA-1 is +not the executable integrity pin; a Git source identity and an executable +SHA-256 have different roles. Fixed-origin TLS plus the same public member key +and authenticated ready event attest the receipt; none claims Authenticode. +No full installer build, signing, activation, or audible acceptance is claimed. + +CI verify explicitly runs test_bekci_watch.py and test_native_speech_mux.py using +ci-venv pytest in addition to the unchanged unittest suite. Both focused files +pass on Linux without skips. Tests that execute native-watch.exe reside in the +explicitly Windows-only test_native_watch_cleanup.py; their owned-process proof +is Windows evidence, not Linux CI runtime coverage. + +Sideband JSON extraction and SSE filtering apply only to the resolved exact +Sesli-Hizli-Zeki target, including when no watch owner is active. Other models +retain their original Jev/response pipeline without the sideband frame buffer. +After the final scope correction, rebuild/provision from final source; the older +local candidate hash proof does not include this last kopru.py change. diff --git a/apps/desktop/assets/codex-bridge/NativeWatchBootstrap.cs b/apps/desktop/assets/codex-bridge/NativeWatchBootstrap.cs new file mode 100644 index 0000000..213ade0 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/NativeWatchBootstrap.cs @@ -0,0 +1,89 @@ +// Thin Windows CLI_PATH executable. Runtime and original are owner-provisioned, +// absolute, SHA-pinned; never consult PATH or CODEX_CLI_PATH. +using System; +using System.IO; +using System.Text; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Collections.Generic; +using System.Threading; +using System.Runtime.InteropServices; +using System.Web.Script.Serialization; +class NativeWatchBootstrap { + // Assign this bootstrap BEFORE creating Python. Descendants inherit job + // membership atomically; the non-inheritable handle belongs only to us. + // Windows closes it even on TerminateProcess, killing Python and native. + static IntPtr ownedJob; + [StructLayout(LayoutKind.Sequential)] struct BasicLimits { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinWorkingSet, MaxWorkingSet; + public uint ActiveProcesses; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { + public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; + } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + static void OwnProcessTree() { + ownedJob=CreateJobObject(IntPtr.Zero,null); + var limits=new ExtendedLimits(); limits.Basic.Flags=0x2000; // KILL_ON_JOB_CLOSE + if(ownedJob==IntPtr.Zero || !SetInformationJobObject(ownedJob,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits))) + || !AssignProcessToJobObject(ownedJob,Process.GetCurrentProcess().Handle)) throw new Exception(); + // Do not close here or in finally: the bootstrap is itself a member. + // The OS closes this handle at exit, preserving our native exit code. + } + static string Quote(string s) { + var b=new StringBuilder("\""); int slashes=0; + foreach(char c in s) { + if(c=='\\') {slashes++; continue;} + if(c=='"') { b.Append('\\',slashes*2+1); b.Append(c); } + else {b.Append('\\',slashes); b.Append(c);} + slashes=0; + } + b.Append('\\',slashes*2); b.Append('"'); return b.ToString(); + } + static string Pin(Dictionary c,string field) { + string p=(string)c[field], expected=(string)c[field+"_sha256"]; + if(!Path.IsPathRooted(p) || String.Equals(Path.GetFullPath(p),Process.GetCurrentProcess().MainModule.FileName,StringComparison.OrdinalIgnoreCase)) throw new Exception(); + using(var f=File.OpenRead(p)) using(var sha=SHA256.Create()) { + string actual=BitConverter.ToString(sha.ComputeHash(f)).Replace("-","").ToLowerInvariant(); + if(actual!=expected.ToLowerInvariant()) throw new Exception(); + } + return p; + } + static void Pump(Stream source, Stream target) { + byte[] buffer=new byte[8192]; int count; + while((count=source.Read(buffer,0,buffer.Length))>0) { target.Write(buffer,0,count); target.Flush(); } + } + static int Main(string[] args) { + try { + string manifest=Path.ChangeExtension(Process.GetCurrentProcess().MainModule.FileName,".json"); + var c=new JavaScriptSerializer().Deserialize>(File.ReadAllText(manifest)); + string original=Pin(c,"original"); + string python=Pin(c,"python"); + string script=Pin(c,"script"); + OwnProcessTree(); + var argv=new List{"-u",script,manifest}; argv.AddRange(args); + var quoted=new List(); foreach(var a in argv) quoted.Add(Quote(a)); + var p=Process.Start(new ProcessStartInfo(python,String.Join(" ",quoted.ToArray())) { UseShellExecute=false, CreateNoWindow=true, RedirectStandardInput=true, RedirectStandardOutput=true, RedirectStandardError=true }); + var input=new Thread(()=> { try { Pump(Console.OpenStandardInput(),p.StandardInput.BaseStream); p.StandardInput.Close(); } catch(IOException) {} }); + input.IsBackground=true; input.Start(); + var output=new Thread(()=>Pump(p.StandardOutput.BaseStream,Console.OpenStandardOutput())); + var error=new Thread(()=>Pump(p.StandardError.BaseStream,Console.OpenStandardError())); + output.Start(); error.Start(); + p.WaitForExit(); output.Join(); error.Join(); return p.ExitCode; + } catch { Console.Error.WriteLine("Owned native launcher integrity/configuration failure."); return 78; } + } +} diff --git a/apps/desktop/assets/codex-bridge/SES-LI-ENTEGRASYON.md b/apps/desktop/assets/codex-bridge/SES-LI-ENTEGRASYON.md new file mode 100644 index 0000000..436b1b5 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/SES-LI-ENTEGRASYON.md @@ -0,0 +1,44 @@ +# Codex sesli entegrasyon incelemesi — 12 Ekim 2026 + +## Uygulanan değişiklik ve doğrulama + +Çözülmüş hedef tam `Sesli-Hizli-Zeki` ise nihai metin Jev'e gönderilmez. Büyük/küçük harf, boşluk, `ide/` öneki veya sonek normalizasyonu yapılmaz. `ide/` kullanıcı slug'ı önce mevcut mapping ile hedefe çözülür. Taşıma hatası, boş yanıt ve kopan akışın katman kurtarması korunur; yedek katmana geçince muafiyet taşınmaz. Araç/commentary hazırlık bypass'ı korunur. İstisna yanıt tamponlamasını değiştirmez. + +`test_jev_denetim.py` exact hedefi, benzer isimleri ve fallback katmanının tekrar denetlenmesini çalıştırır. `test_denetim_ek.py` gerçek `_responses` metodu üzerinden başarılı yanıt, bağlantı kopması ve HTTP 400 yollarında çözülmüş hedef aktarımını, `instructions` ve developer mesajının korunmasını doğrular. Bunlar mevcut handler test düzeneğini kullanır; canlı sağlayıcı testi değildir. + +Paket README'sindeki komutla `py -3 -m unittest discover`: **185 test, 25.305 saniye, OK**. Mevcut testlerde ResourceWarning ve beklenen yerel bağlantı reddi çıktısı vardı. İlk yeni test eski imzada hata verdi. Ayrıca muafiyet fonksiyonu geçici mock ile False yapıldığında yeni test gerçek davranış farkıyla başarısız oldu: özgün cevap yerine Katman-1 cevabı geldi (`MUTATION_DETECTED=True`). + +## Gerçek Codex ses yolu + +İncelenen istemci: WindowsApps `OpenAI.Codex_26.1007.2314.0_x64__2p2nqsd0c76g0`, `app/resources/app.asar`, salt okunur. CLI: `codex-cli 0.155.1`. `codex app-server generate-ts --experimental --out ` ile yerel binary protokolü üretildi. + +Protokolün `ClientRequest.ts` dosyasında gerçek yöntemler: + +- `thread/realtime/appendText`: `{threadId, text, role}`; role = user/developer/assistant. Girdi ekler; tek başına ses üretimini garanti ettiği iddia edilmez. +- `thread/realtime/appendSpeech`: `{threadId, text}`; açıklaması “append speakable text to thread realtime”. +- `thread/realtime/start`: thread kapsamlı oturum; websocket, webrtc veya existingCall taşıması. `clientManagedHandoffs` varsayılan false; automatic Codex response handoff seçenekleri var. Başka oturumun existingCall bağlantısı kullanılmadı. +- `turn/start`, `turn/steer`, `thread/queue/add`, `thread/queue/start` var. `turn/steer` aktif `expectedTurnId` ister; bitmiş tura uygulanacak genel uyandırma yolu değildir. `codex queue --thread ... --message ...` mevcut CLI yüzeyidir; kuyruğa koymanın ses çıkışıyla aynı şey olduğu doğrulanmadı. + +İstemcinin `webview/assets/app-initial-b8e1c8014bb4.js` dosyasında `cra` ve `lra`, sırasıyla appendText/appendSpeech RPC'sini `manager.sendRequest` ile çağırıyor. Realtime runtime aynı yöntemleri dışarı sunuyor; durdurulmuş durumda no-op. **Somut arka plan tüketicisi:** kullanım limiti yaklaşma bildirimi geldiğinde istemci `appendSpeech` çağırıp uyarıyı sesli söylüyor (`[Composer] failed to speak realtime usage warning` hata kolu). Bu, kullanıcı yeni mesaj yazmadan konuşma istemenin mevcut istemci mekanizması olduğunu kanıtlar. Bekçi düzeltmesinin canlı ses çıkışı test edilmiş değildir. + +`CompilerIntrinsics-87ea46a69911.js` içinde WebRTC data channel üzerinden `conversation.item.create` gönderimi de var. Aynı aramada `response.created` olay işleyicisi görüldü; bunu `response.create` gönderim kanıtı saymıyoruz. Ham Realtime protokolü icat etmek yerine mevcut thread realtime RPC'si entegrasyon adayıdır. + +## Köprüdeki somut eksik + +`bridge.py:H._isle` yalnız `/responses` ve model listesini alt köprüye yollar; diğer yollar, `/realtime/calls` dahil, doğrudan ChatGPT'ye gider. WebSocket upgrade 426 ile reddedilir. Dolayısıyla `kopru.py:H._passthrough` içindeki isteğe bağlı `voice_prep.baslat(Location, headers, ...)` kancası standart ayırıcı topolojisinde çağrı oluşturmayı göremez. + +`voice_prep.py` ve `ses_tuneli.py` kaynak worktree ve kurulu AITURK resources/codex-bridge içinde yok. Yerel aramada `Temp/oldb/voice_prep.py` yedek kopyası bulundu ve yalnız okundu: `Hazirlayici` aynı call'a `wss://api.openai.com/v1/live/` sideband açıyor; `session.context.append` ile bağlam ekliyor. Bu eski dosyada response.create/conversation.item.create gönderimi bulunmadı. Yedek dosya kurulu/aktif bileşen kanıtı değildir; çalıştırılmadı veya kopyalanmadı. `ses_tuneli` bulunamadı. + +## Bekçi entegrasyonunda kalanlar + +Diğer ajanın auth + session + cursor notices SSE sözleşmesi henüz bu çalışmaya ulaşmadı. Sözleşme sonrası bildirimin doğrulanmış session kimliğini doğru Codex host/thread ve canlı realtime ömrüne bağlamak gerekiyor. SSE yeniden bağlanma/cursor tekrarlarında aynı düzeltmeyi iki kez seslendirmeme, bitmiş ses oturumuna göndermeme ve yeni kullanıcı konuşmasıyla çakışmayı ele alma davranışları kabul testine dahil edilmeli. Bunlar uygulanmış sözleşme değildir. + +Yalnız bize ait deney oturumunda, ilk ses cevabı bittikten sonra bir notice üretilip mevcut app-server yetkili bağlantısından `thread/realtime/appendSpeech` ile düzeltme konuşmasının başlaması; başka thread'in etkilenmemesi; yeni kullanıcı mesajı gerekmemesi gözlenmeli. Bu canlı test yapılmadan “entegrasyon tamamlandı” denemez. Hermes masaüstü TTS/voice hook'ları bu Codex kanalının yerine kullanılmadı. İmzalı istemciye API eklenmedi. + +## Kokla / Codex alt ajan yolu + +Mevcut yerel `C:/Users/maste/.codex/skills/codex-kokla-ajan/SKILL.md` açıkça `ide/GPT-6-Astra` CLI slug'ını, `GPT-6-Astra` OmniRoute combo kimliğini belirtiyor. `gpt-6-astra` tek modelini combo yerine kullanmayı yasaklıyor. Burada katalog yeniden sorgulanmadı; bilgi skill'in kaydıdır. + +Mevcut CLI yolu: `codex exec --model 'ide/GPT-6-Astra' --color never -C '' -o '' ''`. `--json` süreç olaylarını, `exec resume` düzeltme devamını destekliyor. Skill ana ajana süreç kimliği, hedef, kalanlar, sonuç dosyası kaydı; çıktı takibi; bağımsız diff/test kontrolü; sıkıştırma sonrası kayıt ve kanıt okuma sorumluluğu veriyor. Bu görevde yeni bir CLI alt ajanı başlatılmadı. Runtime'ın sunulan `spawn_agent` aracı çağrısı `unsupported call: spawn_agent` döndü; bu nedenle kullanılabilir canlı alt ajan oluşturduğu iddia edilmez. + +`C:/Users/maste/.codex/hooks/codex_continuity.py` save işlemi session/cwd kapsamlı; PreCompact yedekliyor, SessionStart/UserPromptSubmit geri yüklüyor, Stop aktif kayıtta devam uyarısı veriyor. Tamamlanma için kalan iş olmaması ve kanıt dosyası şartı var. Bu kayıt bağımsız test yerine geçmez. Runtime plugin CLI add/list/remove sağlar; istemcinin özel JS fonksiyonlarına genel plugin erişimi kanıtlanmadı. AITURK IDE devri yapılmadı. diff --git a/apps/desktop/assets/codex-bridge/bekci_watch.py b/apps/desktop/assets/codex-bridge/bekci_watch.py new file mode 100644 index 0000000..bb9fdb5 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/bekci_watch.py @@ -0,0 +1,188 @@ +"""Public Omni receipts. All scopes here are LOCAL opaque identities, never backend IDs.""" +import hashlib +import http.client +import json +import socket +import sqlite3 +import ssl +import threading +import time +from urllib.parse import urlsplit +from native_speech_mux import Binding, WatchReceiptAdapter + +EVENTS_PATH = '/v1/bekci/events' +TRUSTED_ORIGIN = 'https://ai.turkservis.online' +# Public protocol lifetime, with bounded server/client clock skew. The server's +# expiry remains the watch deadline; there is no shorter research timeout. +MAX_RECEIPT_TTL = 24 * 60 * 60 +RECEIPT_CLOCK_SKEW = 60 + +def opaque(*parts): + return hashlib.sha256('\0'.join(parts).encode()).hexdigest() + +class Frames: + def __init__(self): + self.buf = b'' + def feed(self, chunk): + self.buf += chunk + if len(self.buf) > 262144: + raise ValueError('SSE frame too large') + while True: + # Normalize only complete CRLF, including a CR split across reads. + self.buf = self.buf.replace(b'\r\n', b'\n') + if b'\n\n' not in self.buf: + return + frame, self.buf = self.buf.split(b'\n\n', 1) + event, data, cursor = '', [], None + for line in frame.decode('utf-8').split('\n'): + field, _, value = line.partition(':') + value = value.removeprefix(' ') + if field == 'event': event = value + elif field == 'data': data.append(value) + elif field == 'id': cursor = value + yield event, '\n'.join(data), cursor, frame + b'\n\n' + +class MetadataFilter: + """Strip sideband before rewrites, logging or transcript observers.""" + def __init__(self, accept): + self.frames, self.accept = Frames(), accept + def feed(self, chunk): + out = [] + for event, data, _, raw in self.frames.feed(chunk): + if event == 'bekci.watch': + try: self.accept(json.loads(data)) + except (ValueError, TypeError): pass + else: out.append(raw) + return b''.join(out) + def flush(self): + # An incomplete sideband frame must never escape into model text. + raw, self.frames.buf = self.frames.buf, b'' + return b'' if b'bekci.watch' in raw else raw + +class Ledger: + """Commit uncertain BEFORE enqueue. Crash ambiguity sacrifices delivery, never resends.""" + def __init__(self, path): + self.db = sqlite3.connect(path, check_same_thread=False) + self.lock = threading.Lock() + self.db.execute('PRAGMA journal_mode=WAL') + self.db.execute('PRAGMA synchronous=FULL') + self.db.executescript('CREATE TABLE IF NOT EXISTS delivery(scope TEXT,id TEXT,status TEXT,PRIMARY KEY(scope,id)); CREATE TABLE IF NOT EXISTS cursors(scope TEXT PRIMARY KEY,cursor TEXT); CREATE TABLE IF NOT EXISTS bindings(scope TEXT PRIMARY KEY,epoch TEXT);') + def bind_epoch(self, scope, correlation): + epoch = opaque(correlation.connection_id,correlation.thread_id,str(correlation.generation)) + with self.lock, self.db: + self.db.execute('INSERT OR IGNORE INTO bindings VALUES(?,?)',(scope,epoch)) + return self.db.execute('SELECT epoch FROM bindings WHERE scope=?',(scope,)).fetchone()[0] == epoch + def claim(self, scope, nid): + with self.lock, self.db: + return self.db.execute('INSERT OR IGNORE INTO delivery VALUES(?,?,?)', (scope,nid,'uncertain')).rowcount == 1 + def ack(self, scope, nid, cursor, status): + with self.lock, self.db: + self.db.execute('UPDATE delivery SET status=? WHERE scope=? AND id=?', (status,scope,nid)) + self.db.execute('INSERT OR REPLACE INTO cursors VALUES(?,?)', (scope,cursor)) + def cursor(self, scope): + with self.lock: + row = self.db.execute('SELECT cursor FROM cursors WHERE scope=?',(scope,)).fetchone() + return row[0] if row else None + +class WatchOwner: + def __init__(self, mux, ledger, *, origin=TRUSTED_ORIGIN, test_loopback=False): + u = urlsplit(origin) + if origin != TRUSTED_ORIGIN and not (test_loopback and u.scheme == 'http' and u.hostname == '127.0.0.1'): + raise ValueError('untrusted Omni origin') + self.mux, self.ledger, self.origin = mux, ledger, u + self.adapter = WatchReceiptAdapter(mux) + self.lock, self.watches = threading.Lock(), {} + def capture(self, headers, body): + return self.adapter.capture(headers, body) + def live(self, c): + s = self.mux.snapshot(); t = s['threads'].get(c.thread_id, {}) + return (not s['closed'] and not s['injection_disabled'] and s['connection_id'] == c.connection_id + and t.get('active') and t.get('generation') == c.generation) + def accept(self, payload, correlation, key, route_url): + if not correlation or not self.live(correlation): return 'stale_generation' + route = urlsplit(route_url) + if (route.scheme,route.netloc) != (self.origin.scheme,self.origin.netloc) or not key: return 'untrusted_route' + if (not isinstance(payload,dict) or set(payload) != {'subscription','events_url','expires_at'} + or payload['events_url'] != EVENTS_PATH or not isinstance(payload['subscription'],str) + or not 20 <= len(payload['subscription']) <= 128 or type(payload['expires_at']) not in (int,float) + or not time.time()*1000 < payload['expires_at'] <= (time.time()+MAX_RECEIPT_TTL+RECEIPT_CLOCK_SKEW)*1000): return 'invalid_receipt' + scope = opaque('public-watch',key,payload['subscription']) + if not self.ledger.bind_epoch(scope,correlation): return 'receipt_replay_mismatch' + with self.lock: + if scope in self.watches: return 'already_watching' + if len(self.watches) >= 256: return 'capacity_exhausted' + stop = threading.Event() + self.watches[scope] = stop + threading.Thread(target=self._watch,args=(dict(payload),correlation,key,scope,stop),daemon=True).start() + return 'subscribing' + def close(self): + with self.lock: + for stop in self.watches.values(): stop.set() + def _watch(self, p, c, key, scope, stop): + # These are local labels only. TLS+same-key authenticated ready attests + # receipt validity; server owns literal tenant/session/history mapping. + binding = Binding(opaque('member',key),scope,opaque('history-receipt',p['subscription']),c.connection_id,c.thread_id,c.generation) + deadline = p['expires_at']/1000 + try: + for attempt in range(8): + if stop.is_set() or time.time() >= deadline or not self.live(c): break + conn = (http.client.HTTPSConnection(self.origin.hostname,self.origin.port,context=ssl.create_default_context(),timeout=10) + if self.origin.scheme == 'https' else http.client.HTTPConnection(self.origin.hostname,self.origin.port,timeout=10)) + finished = threading.Event() + def guard(): + while not finished.wait(.05): + if stop.is_set() or time.time() >= deadline or not self.live(c): + stop.set() + if conn.sock: + try: conn.sock.shutdown(socket.SHUT_RDWR) + except OSError: pass + conn.close() + return + guard_thread = threading.Thread(target=guard,daemon=True) + guard_thread.start() + try: + body = {'subscription':p['subscription']} + cursor = self.ledger.cursor(scope) + if cursor: body['cursor'] = cursor + conn.request('POST', EVENTS_PATH, json.dumps(body), {'Authorization':'Bearer '+key,'Content-Type':'application/json'}) + resp = conn.getresponse() + if resp.status != 200 or 'text/event-stream' not in resp.getheader('Content-Type',''): break + parser, ready = Frames(), False + while self.live(c) and not stop.is_set() and time.time() < deadline: + chunk = resp.read1(4096) + if not chunk: break + for event,data,event_id,_ in parser.feed(chunk): + if not data: continue # keepalive + value = json.loads(data) + if event == 'ready': + if not isinstance(value,dict) or not isinstance(value.get('cursor'),str): raise ValueError('invalid ready') + ready = self.live(c) and self.mux.bind(binding) + if not ready: return + elif event == 'notice': + if not ready: raise ValueError('notice before ready') + self._notice(binding,scope,value,event_id,deadline,c) + except (OSError,ValueError,http.client.HTTPException): + pass # no credential-bearing exceptions in logs + finally: + finished.set(); conn.close(); guard_thread.join(timeout=1) + if stop.wait(min(2**attempt,15)): break + finally: + with self.mux._lock: + k = (binding.tenant_id,binding.bekci_session_id) + if self.mux._bindings.get(k) == binding: self.mux._bindings.pop(k,None) + with self.lock: self.watches.pop(scope,None) + def _notice(self,b,scope,value,event_id,deadline,c): + if not self.live(c) or not isinstance(value,dict): return + n, cursor = value.get('notice'), value.get('cursor') + if (not isinstance(n,dict) or not isinstance(cursor,str) or not 0 < len(cursor) <= 512 + or event_id != cursor or not isinstance(n.get('id'),str) or not 0 < len(n['id']) <= 256 + or n.get('type') not in ('duzeltme','arastirma') or not isinstance(n.get('metin'),str) + or not 0 < len(n['metin']) <= 8000): return + if not self.ledger.claim(scope,n['id']): + self.ledger.ack(scope,n['id'],cursor,'replay_suppressed'); return + now = time.time() + notice = dict(notice_id=n['id'],tenant_id=b.tenant_id,bekci_session_id=b.bekci_session_id, + history_revision=b.history_revision,connection_id=b.connection_id,thread_id=b.thread_id, + expected_generation=b.generation,issued_at=now,expires_at=min(now+60,deadline),text=n['metin']) + result = self.mux.submit(notice) + self.ledger.ack(scope,n['id'],cursor,result['status']) diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index a19f2db..34fe39f 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -32,7 +32,7 @@ import urllib.error import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) -SURUM = "9" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) +SURUM = "10" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) try: with open(os.path.abspath(__file__), "rb") as _f: KOD = hashlib.sha256(_f.read()).hexdigest()[:8] diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index fba4a3f..e416342 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -32,6 +32,11 @@ import is_hatti import internet import jev_denetim +from bekci_watch import MetadataFilter + +# Installed only by owned native launcher, never an HTTP control endpoint. +WATCH_OWNER = None + SURUM = "8" HERE = os.path.dirname(os.path.abspath(__file__)) try: @@ -122,7 +127,7 @@ seq = [0] def log(msg): line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg - print(line, flush=True) + print(line, file=sys.stderr, flush=True) with lock: with open(os.path.join(LOGDIR, "bridge.log"), "a", encoding="utf-8") as f: f.write(line + "\n") @@ -1071,6 +1076,8 @@ class H(http.server.BaseHTTPRequestHandler): body = self._read() path = self.path if path.startswith("/aiturk/codex-baglama/"): + if WATCH_OWNER is not None: + return self._send_json(404, {"error": "not available on native owner"}) 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)}") @@ -1391,6 +1398,7 @@ class H(http.server.BaseHTTPRequestHandler): except Exception as e: log(f"[{n} responses] govde cozulemedi: {e}") return self._send_json(400, {"error": {"message": f"bridge: govde cozulemedi: {e}"}}) + self.watch_correlation = WATCH_OWNER.capture(dict(self.headers), req) if WATCH_OWNER else None slug = req.get("model", "") gelen = slug # Codex'in bu sohbet icin gonderdigi ad (listeden secilen model) if not ek_model_mu(gelen): @@ -1669,6 +1677,23 @@ class H(http.server.BaseHTTPRequestHandler): break log(f"{tag} deneme {attempt}: {e}") time.sleep(min(2 * attempt, 8)); continue + # Only the resolved exact voice target owns this sideband protocol. + # Other models retain their original response/Jev/stream pipeline. + if target == 'Sesli-Hizli-Zeki': + # Capture was taken before rewriting/request dispatch. Only this exact + # route credential may authenticate the returned public receipt. + correlation = self.watch_correlation + accept_watch = lambda metadata: WATCH_OWNER.accept(metadata, correlation, route['key'], route['url']) if WATCH_OWNER else None + if 'application/json' in str(resp.headers.get('Content-Type', '')): + try: + reply = json.loads(resp.read()) + if isinstance(reply, dict): + metadata = reply.pop('_bekci_watch', None) + if metadata is not None: accept_watch(metadata) + return self._send_json(resp.status, reply) + finally: + resp.close() + resp = WatchResponse(resp, accept_watch) # 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), en_az=ozet_kopru.en_az_karakter(girdi_bayt, ozet_cfg)) @@ -1688,6 +1713,24 @@ class H(http.server.BaseHTTPRequestHandler): do_DELETE = _handle do_PATCH = _handle +class WatchResponse: + """Remove metadata before any response rewriter, log or history observer.""" + def __init__(self, response, accept): + self.response, self.filter = response, MetadataFilter(accept) + self.done = False + def __getattr__(self, name): + return getattr(self.response, name) + def read1(self, size): + while not self.done: + raw = self.response.read1(size) + if raw: + data = self.filter.feed(raw) + if data: return data + else: + self.done = True + return self.filter.flush() + return b'' + class TS(http.server.ThreadingHTTPServer): daemon_threads = True # Windows'ta SO_REUSEADDR baska bir surecin ayni portu "calmasina" izin verir. diff --git a/apps/desktop/assets/codex-bridge/native_speech_mux.py b/apps/desktop/assets/codex-bridge/native_speech_mux.py new file mode 100644 index 0000000..1f0a82e --- /dev/null +++ b/apps/desktop/assets/codex-bridge/native_speech_mux.py @@ -0,0 +1,573 @@ +"""Owned native Codex stdio adapter; inert until explicitly launched by an owner. + +Only a trusted in-process root adapter may install Binding objects. There is no +HTTP bind/RPC endpoint, tenant inference, or activated SSE subscription. +appendSpeech acceptance is not proof of audible speech or history persistence. +""" +from __future__ import annotations + +import hmac +import hashlib +import json +import queue +import secrets +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import BinaryIO, TypedDict, Callable + + +@dataclass(frozen=True) +class WatchReceipt: + """INTERNAL adapter contract, not a claim about backend wire field names. + + Backend resolver authenticates an opaque handle using the member credential + against a trusted, configured Omni endpoint. Backend secret stays server-side. + The resolver must verify tenant, source request thread and history together. + No URL or identity supplied by a notice is trusted as subscription authority. + """ + opaque_handle: str + tenant_id: str + bekci_session_id: str + history_revision: str + thread_id: str + expires_at: float + + +@dataclass(frozen=True) +class RequestCorrelation: + """Captured by the owned HTTP bridge from the actual incoming request. + + Must be captured before metadata arrives, not reconstructed from its body. + generation is the mux epoch observed at request capture, never the latest tab. + """ + connection_id: str + thread_id: str + generation: int + + +class WatchReceiptAdapter: + """One consumer; root chooses transport/decoder after backend agreement. + + decode extracts only the opaque handle from bekci.watch SSE data or the + nonstream _bekci_watch value. resolve performs authenticated backend lookup. + Missing agreement is fail-closed, not fabricated ready metadata. + """ + def __init__(self, mux, *, decode: Callable[[object], str] | None = None, + resolve: Callable[[str], WatchReceipt] | None = None): + self.mux, self.decode, self.resolve = mux, decode, resolve + self._accepted = {} + + def capture(self, headers: dict, body: dict) -> RequestCorrelation | None: + normalized = {k.lower(): v for k, v in headers.items()} + values = [normalized.get('session-id'), normalized.get('thread-id'), body.get('prompt_cache_key')] + if not all(isinstance(v, str) and v for v in values) or len(set(values)) != 1: + return None + snap = self.mux.snapshot() + state = snap['threads'].get(values[0], {}) + if snap['closed'] or not state.get('active'): + return None + return RequestCorrelation(snap['connection_id'], values[0], state['generation']) + + def accept(self, transport: str, payload: object, correlation: RequestCorrelation | None) -> str: + if transport not in ('bekci.watch', '_bekci_watch'): + return 'unsupported_metadata_transport' + if self.decode is None or self.resolve is None: + return 'backend_contract_not_established' + if correlation is None or correlation.connection_id != self.mux.connection_id: + return 'request_correlation_missing' + try: + handle = self.decode(payload) + if not isinstance(handle, str) or not 1 <= len(handle) <= 4096: + return 'invalid_receipt' + receipt = self.resolve(handle) + except Exception: + return 'receipt_verification_failed' # never expose credential-bearing exception text + if not isinstance(receipt, WatchReceipt) or receipt.opaque_handle != handle: + return 'invalid_receipt' + if (receipt.thread_id != correlation.thread_id or type(receipt.expires_at) not in (int, float) + or not time.time() < receipt.expires_at <= time.time() + 3600 + or not all(isinstance(v, str) and 0 < len(v) <= 256 for v in + (receipt.tenant_id, receipt.bekci_session_id, receipt.history_revision, receipt.thread_id))): + return 'receipt_mismatch_or_expired' + binding = Binding(receipt.tenant_id, receipt.bekci_session_id, receipt.history_revision, + correlation.connection_id, receipt.thread_id, correlation.generation) + previous = self._accepted.get(handle) + if previous is not None and previous != (receipt, binding): + return 'receipt_replay_mismatch' + if previous is None and len(self._accepted) >= 4096: + return 'capacity_exhausted' + if not self.mux.bind(binding): + return 'stale_generation' + self._accepted[handle] = (receipt, binding) + return 'bound' + + def notice(self, handle: str, notice: Notice) -> dict: + accepted = self._accepted.get(handle) + if accepted is None: + return {'status': 'binding_not_established'} + receipt, binding = accepted + if time.time() >= receipt.expires_at: + return {'status': 'receipt_expired'} + expected = dict(tenant_id=binding.tenant_id, bekci_session_id=binding.bekci_session_id, + history_revision=binding.history_revision, thread_id=binding.thread_id, + connection_id=binding.connection_id, expected_generation=binding.generation) + if not isinstance(notice, dict) or any(notice.get(k) != v for k, v in expected.items()): + return {'status': 'binding_mismatch'} + return self.mux.submit(notice) + + +def verified_original(path: str, expected_sha256: str) -> str: + """Owner-pinned absolute original; no PATH/override discovery or extra engine.""" + candidate = Path(path) + if not candidate.is_absolute() or not candidate.is_file() or candidate.resolve() == Path(__file__).resolve(): + raise ValueError('original executable must be an explicitly pinned absolute file') + if len(expected_sha256) != 64: + raise ValueError('original executable hash required') + with candidate.open('rb') as stream: + actual = hashlib.file_digest(stream, 'sha256').hexdigest() + if not hmac.compare_digest(actual, expected_sha256.lower()): + raise ValueError('original executable hash mismatch') + return str(candidate.resolve()) + + +def stdio_invocation(args: list[str]) -> bool: + """Installed Desktop tc()/Ks() grammar, preserving every original argument.""" + seen, i = False, 0 + while i < len(args): + arg = args[i] + if arg in ('-c', '--config') and i + 1 < len(args): + i += 2 + continue + if arg == 'app-server' and not seen: + seen = True + elif seen and arg in ('--stdio', '--analytics-default-enabled'): + pass + elif seen and arg == '--listen' and args[i+1:i+2] == ['stdio://']: + i += 1 + else: + return False + i += 1 + return seen + +def launch_original(path: str, expected_sha256: str, args: list[str], *, + attach: Callable[[WatchReceiptAdapter], None] | None = None) -> int: + """Source launcher entry for a future normal bridge module copy. + + No activation/config mutation. Unknown invocation grammar passes native stdio + directly. Owner callback wires only this consumer after backend agreement. + No second engine, binary patch, token file or discovery of current UI tab. + """ + original = verified_original(path, expected_sha256) + observer = stdio_invocation(args) + mux = NativeSpeechMux([original, *args], observer=observer) + if observer and attach is not None: + try: + attach(WatchReceiptAdapter(mux)) + except Exception: + # Optional adapter failure cannot take down native stdio. Invalidate + # any partial setup so no correction can escape a failed attachment. + mux._disabled = True + mux._bindings.clear() + return mux.run() + + +class Notice(TypedDict): + notice_id: str + tenant_id: str + bekci_session_id: str + history_revision: str + connection_id: str + thread_id: str + expected_generation: int + issued_at: float + expires_at: float + text: str + + +@dataclass(frozen=True) +class Binding: + """Local scope labels (legacy field names), NOT literal server identity claims. + + Trusted owner attests this opaque receipt scope belongs to this native epoch. + + Install only from independently verified mapping, NEVER from notice fields. + Rebind explicitly for a new history revision or realtime generation. + """ + tenant_id: str + bekci_session_id: str + history_revision: str + connection_id: str + thread_id: str + generation: int + + +def _object(raw: bytes) -> dict: + try: + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except (ValueError, UnicodeError): + return {} + + +def _id(value): + return (type(value), value) if type(value) in (str, int) else None + + +class NativeSpeechMux: + """One original process, bounded FIFO, one writer in each direction. + + argv is the original executable plus its original arguments. observer=True + must only be selected for a verified stdio app-server invocation. The CLI + deliberately recognizes only the exact ['app-server'] invocation; all other + invocations inherit the original stdio directly. No environment/cwd rewrite. + """ + def __init__(self, argv: list[str], *, observer: bool = False): + self.argv = list(argv) + self.observer = observer + self.connection_id = secrets.token_hex(24) + self._prefix = 'bekci-speech-' + secrets.token_hex(24) + ':' + self._token = secrets.token_urlsafe(32) + self._lock = threading.RLock() + self._queue = queue.Queue(maxsize=32) + self._ended = threading.Event() + self._eof = False + self._disabled = False + self._threads = {} + self._starts = set() + self._bindings = {} + self._receipts = {} + self._pending = {} + self._counter = 0 + self._server = None + self._process = None + + def snapshot(self) -> dict: + with self._lock: + return {'connection_id': self.connection_id, + 'binding_status': 'established' if self._bindings else 'binding_not_established', + 'threads': {k: dict(v) for k, v in self._threads.items()}, + 'closed': self._eof or self._ended.is_set(), + 'injection_disabled': self._disabled} + + def bind(self, binding: Binding) -> bool: + """Trusted local API only. No untrusted endpoint can invoke this.""" + with self._lock: + st = self._threads.get(binding.thread_id, {}) + if (binding.connection_id != self.connection_id or not st.get('active') + or st.get('generation') != binding.generation or self._eof + or self._ended.is_set()): + return False + self._bindings[(binding.tenant_id, binding.bekci_session_id)] = binding + return True + + def _invalidate(self, thread): + if not isinstance(thread, str): + return + if thread in self._threads: + self._threads[thread]['active'] = False + self._bindings = {k: b for k, b in self._bindings.items() if b.thread_id != thread} + + def _observe_client(self, obj): + with self._lock: + rid = _id(obj.get('id')) + # Responses to server requests may legitimately reuse our ID. + if 'method' in obj and isinstance(obj.get('id'), str) and obj['id'].startswith(self._prefix): + self._disabled = True + self._pending.pop(obj['id'], None) + if obj.get('method') in ('thread/start', 'thread/resume', 'thread/fork') and rid: + if len(self._starts) < 4096: + self._starts.add(rid) + else: + self._disabled = True + if obj.get('method') in ('thread/realtime/stop', 'thread/realtime/start', 'thread/unsubscribe'): + params = obj.get('params') + if isinstance(params, dict): + self._invalidate(params.get('threadId')) + + def _observe_server(self, obj) -> bool: + with self._lock: + rid = obj.get('id') + response = 'method' not in obj and (('result' in obj) != ('error' in obj)) + if response and isinstance(rid, str) and rid in self._pending: + key = self._pending.pop(rid) + self._receipts[key]['status'] = 'rpc_error' if 'error' in obj else 'rpc_accepted' + return True + if response and _id(rid) in self._starts: + self._starts.discard(_id(rid)) + result = obj.get('result') + thread = result.get('thread', {}) if isinstance(result, dict) else {} + tid = thread.get('id') if isinstance(thread, dict) else None + if isinstance(tid, str): + self._threads.setdefault(tid, {'generation': 0, 'active': False, 'realtime_session_id': None}) + params = obj.get('params') + if not isinstance(params, dict) or 'id' in obj: + return False + tid = params.get('threadId') + if not isinstance(tid, str): + return False + method = obj.get('method') + if method == 'thread/realtime/started': + self._invalidate(tid) + previous = self._threads.get(tid, {}).get('generation', 0) + self._threads[tid] = {'generation': previous + 1, 'active': True, + 'realtime_session_id': params.get('realtimeSessionId')} + elif method in ('thread/realtime/closed', 'thread/realtime/error'): + self._invalidate(tid) + return False + + def _validate(self, notice): + if not isinstance(notice, dict) or set(notice) != set(Notice.__annotations__): + return 'invalid_schema' + for key in Notice.__annotations__: + if key not in ('expected_generation', 'issued_at', 'expires_at'): + if not isinstance(notice[key], str) or not notice[key].strip() or len(notice[key]) > (8000 if key == 'text' else 256): + return 'invalid_schema' + if type(notice['expected_generation']) is not int or notice['expected_generation'] < 1: + return 'invalid_schema' + if any(type(notice[k]) not in (int, float) for k in ('issued_at', 'expires_at')): + return 'invalid_schema' + now = time.time() + if not (now - 60 <= notice['issued_at'] <= now + 2 and now < notice['expires_at'] <= notice['issued_at'] + 60): + return 'expired_or_invalid_ttl' + if self._eof or self._ended.is_set(): + return 'closed' + if self._disabled or not self.observer: + return 'injection_disabled' + binding = self._bindings.get((notice['tenant_id'], notice['bekci_session_id'])) + if binding is None: + return 'binding_not_established' + if binding != Binding(notice['tenant_id'], notice['bekci_session_id'], notice['history_revision'], + notice['connection_id'], notice['thread_id'], notice['expected_generation']): + return 'binding_mismatch' + st = self._threads.get(binding.thread_id, {}) + if not st.get('active') or st.get('generation') != binding.generation: + return 'stale_generation' + return None + + def submit(self, notice: Notice) -> dict: + with self._lock: + error = self._validate(notice) + if error: + return {'status': error} + key = (notice['tenant_id'], notice['bekci_session_id'], notice['notice_id']) + if key in self._receipts: + return dict(self._receipts[key]) + # Never evict dedupe during this process lifetime or retry uncertain sends. + if len(self._receipts) >= 4096: + return {'status': 'capacity_exhausted'} + receipt = {'status': 'queued'} + self._receipts[key] = receipt + try: + self._queue.put_nowait(('notice', (dict(notice), key))) + except queue.Full: + del self._receipts[key] + return {'status': 'busy'} + return dict(receipt) + + def start_inlet(self) -> tuple[str, str]: + """Return loopback URL and secret to trusted owner IN MEMORY; never log. + + One bounded request at a time; 2s timeout applies only to inlet HTTP reads, + never the original engine. Owner must arrange private credential delivery. + """ + if not self.observer or self._server is not None: + raise ValueError('inlet requires an unused app-server observer') + mux = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def setup(self): + super().setup() + self.connection.settimeout(2) + + def do_POST(self): + try: + auth = self.headers.get('Authorization', '') + if not hmac.compare_digest(auth.encode(), ('Bearer ' + mux._token).encode()): + self.send_error(401) + return + if self.headers.get('Origin') or self.headers.get('Transfer-Encoding'): + self.send_error(400) + return + lengths = self.headers.get_all('Content-Length', []) + if len(lengths) != 1 or not lengths[0].isdigit() or not 0 < int(lengths[0]) <= 65536: + self.send_error(400) + return + raw = self.rfile.read(int(lengths[0])) + if len(raw) != int(lengths[0]): + self.send_error(400) + return + if self.path == '/notice': + result = mux.submit(_object(raw)) + elif self.path == '/status' and _object(raw) == {}: + result = mux.snapshot() + else: + self.send_error(404) + return + body = json.dumps(result).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + except (OSError, ValueError): + return + + self._server = HTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=self._server.serve_forever, daemon=True).start() + return f'http://127.0.0.1:{self._server.server_port}', self._token + + def _put(self, item): + while not self._ended.is_set(): + try: + self._queue.put(item, timeout=.1) + return True + except queue.Full: + pass + return False + + def run(self, source: BinaryIO | None = None, target: BinaryIO | None = None) -> int: + if not self.observer: + return subprocess.call(self.argv) # exact inherited stdin/stdout/stderr/env/cwd + source = source if source is not None else sys.stdin.buffer + target = target if target is not None else sys.stdout.buffer + process = self._process = subprocess.Popen(self.argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + + def read_client(): + fragment = False + while not self._ended.is_set(): + raw = source.readline(65536) + if not raw: + with self._lock: + self._eof = True + self._put(('eof', None)) + return + complete = raw.endswith(b'\n') + if not fragment and complete: + self._observe_client(_object(raw)) + elif not complete: + # Oversized records still forward byte-for-byte, but cannot be + # safely observed. Fail closed for injection for this connection. + with self._lock: + self._disabled = True + fragment = not complete + if not self._put(('raw', raw)): + return + + def send_notice(value): + notice, key = value + with self._lock: + error = self._validate(notice) + if error: + self._receipts[key]['status'] = error + return + self._counter += 1 + rid = self._prefix + str(self._counter) + self._pending[rid] = key + self._receipts[key]['status'] = 'sent_unknown' + raw = json.dumps({'id': rid, 'method': 'thread/realtime/appendSpeech', + 'params': {'threadId': notice['thread_id'], 'text': notice['text']}}, + ensure_ascii=True).encode() + b'\n' + # No lock during blocking pipe I/O: observer must drain server output. + # Protocol has no server-side generation precondition; this race remains. + process.stdin.write(raw) + process.stdin.flush() + + def write_server(): + partial = False + try: + while not self._ended.is_set(): + try: + kind, value = self._queue.get(timeout=.1) + except queue.Empty: + continue + if kind == 'eof': + return + if kind == 'notice': + if partial: + # An oversized frame has disabled injection; reject now. + with self._lock: + self._receipts[value[1]]['status'] = 'injection_disabled' + else: + send_notice(value) + else: + # Re-apply lifecycle invalidation at the actual write boundary + # too: a prior server start may have arrived while queued. + if not partial and value.endswith(b'\n'): + obj = _object(value) + if obj.get('method') in ('thread/realtime/start', 'thread/realtime/stop'): + params = obj.get('params') + if isinstance(params, dict): + with self._lock: + self._invalidate(params.get('threadId')) + process.stdin.write(value) + process.stdin.flush() + partial = not value.endswith(b'\n') + except (OSError, ValueError): + pass + finally: + try: + process.stdin.close() + except OSError: + pass + + reader = threading.Thread(target=read_client, daemon=True) + writer = threading.Thread(target=write_server, daemon=True) + reader.start() + writer.start() + fragment = False + try: + while True: + raw = process.stdout.readline(65536) + if not raw: + break + complete = raw.endswith(b'\n') + consumed = not fragment and complete and self._observe_server(_object(raw)) + if not complete: + with self._lock: + self._disabled = True + fragment = not complete + if not consumed: + target.write(raw) + target.flush() + finally: + self._ended.set() + process.stdout.close() + with self._lock: + self._bindings.clear() + for st in self._threads.values(): + st['active'] = False + for receipt in self._receipts.values(): + if receipt['status'] == 'queued': + receipt['status'] = 'closed' + if self._server: + self._server.shutdown() + self._server.server_close() + return process.wait() # no deadline on legitimate native work + + +def main() -> int: + # Future launcher must pin/verify the original executable, never resolve the + # override recursively. No CODEX_CLI_PATH configuration is performed here. + if len(sys.argv) >= 5 and sys.argv[1] == '--pinned-original' and sys.argv[4] == '--': + try: + return launch_original(sys.argv[2], sys.argv[3], sys.argv[5:]) + except ValueError: + print('original executable verification failed', file=sys.stderr) + return 2 + if len(sys.argv) < 3 or sys.argv[2] != '--': + print('usage: native_speech_mux.py --pinned-original ORIGINAL_EXECUTABLE SHA256 -- ORIGINAL_ARGS...', file=sys.stderr) + return 2 + argv = [sys.argv[1], *sys.argv[3:]] + return NativeSpeechMux(argv, observer=argv[1:] == ['app-server']).run() + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/apps/desktop/assets/codex-bridge/native_watch_launcher.py b/apps/desktop/assets/codex-bridge/native_watch_launcher.py new file mode 100644 index 0000000..12b1cb8 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/native_watch_launcher.py @@ -0,0 +1,42 @@ +"""Owned startup entry. No user environment/config changes or override discovery.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +from native_speech_mux import NativeSpeechMux, stdio_invocation, verified_original +from bekci_watch import Ledger, WatchOwner + +def main(manifest, args): + cfg = json.loads(Path(manifest).read_text(encoding='utf-8-sig')) + original = verified_original(cfg['original'], cfg['original_sha256']) + if not stdio_invocation(args): + return subprocess.call([original,*args]) + # Existing public catalog and original model passthrough handler is reused. + # A per-child CLI setting selects the ephemeral owned bridge; no global file edit. + saved_env = dict(os.environ) + os.environ['AITURK_CODEX_BRIDGE_HOME'] = cfg['bridge_home'] + os.environ['HERMES_HOME'] = cfg['hermes_home'] + try: + import kopru + finally: + os.environ.clear(); os.environ.update(saved_env) + from http.server import ThreadingHTTPServer + server = ThreadingHTTPServer(('127.0.0.1',0), kopru.H) + mux = NativeSpeechMux([original,*args,'-c',f'openai_base_url="http://127.0.0.1:{server.server_port}/backend-api/codex"'],observer=True) + Path(cfg['bridge_home']).mkdir(parents=True,exist_ok=True) + owner = WatchOwner(mux,Ledger(str(Path(cfg['bridge_home'])/'native-watch-ledger.sqlite3'))) + kopru.WATCH_OWNER = owner + threading.Thread(target=server.serve_forever,daemon=True).start() + try: + return mux.run() + finally: + owner.close(); server.shutdown(); server.server_close() + +if __name__ == '__main__': + try: + raise SystemExit(main(sys.argv[1],sys.argv[2:])) + except (OSError,ValueError,KeyError): + sys.stderr.write('Owned native launcher configuration/integrity failure.\n') + raise SystemExit(78) diff --git a/apps/desktop/assets/codex-bridge/prepare_native_watch.py b/apps/desktop/assets/codex-bridge/prepare_native_watch.py new file mode 100644 index 0000000..928c27f --- /dev/null +++ b/apps/desktop/assets/codex-bridge/prepare_native_watch.py @@ -0,0 +1,28 @@ +"""Provision a reviewable launcher candidate. Never sets CODEX_CLI_PATH or restarts apps.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil + +def prepare(source, destination, original, expected_sha256, python, bridge_home, hermes_home): + source, destination = Path(source).resolve(), Path(destination).resolve() + original, python = Path(original).resolve(), Path(python).resolve() + def digest(p): + with p.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest() + if digest(original) != expected_sha256.lower(): raise ValueError('chosen official original hash mismatch') + if original == destination/'native-watch.exe': raise ValueError('recursive original') + if source == destination: raise ValueError('candidate must use separate destination') + destination.mkdir(parents=True,exist_ok=True) + for p in source.rglob('*'): + if p.is_file() and (p.suffix in ('.py','.pyd') or p.name in ('native-watch.exe','LICENSE')) and not p.name.startswith('test_'): + target=destination/p.relative_to(source);target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(p,target) + script=destination/'native_watch_launcher.py' + manifest=dict(original=str(original),original_sha256=expected_sha256.lower(),python=str(python),python_sha256=digest(python),script=str(script),script_sha256=digest(script),bridge_home=str(Path(bridge_home).resolve()),hermes_home=str(Path(hermes_home).resolve())) + (destination/'native-watch.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8') + return destination/'native-watch.exe' + +if __name__=='__main__': + p=argparse.ArgumentParser(description=__doc__) + for name in ('source','destination','original','expected-sha256','python','bridge-home','hermes-home'): p.add_argument('--'+name,required=True) + args=vars(p.parse_args());print(prepare(**args)) diff --git a/apps/desktop/assets/codex-bridge/test_access_programs.py b/apps/desktop/assets/codex-bridge/test_access_programs.py index 4d270fe..e751476 100644 --- a/apps/desktop/assets/codex-bridge/test_access_programs.py +++ b/apps/desktop/assets/codex-bridge/test_access_programs.py @@ -23,7 +23,7 @@ class AccessPrograms(unittest.TestCase): def setUp(self): self.upstream = Mock(return_value=Mock(status=200)) self.env = { - "json": json, + "WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "urllib": Mock(request=Mock(Request=urllib.request.Request, urlopen=self.upstream)), "decompress": lambda body, encoding: body, "MODEL_MAP": {}, diff --git a/apps/desktop/assets/codex-bridge/test_bekci_watch.py b/apps/desktop/assets/codex-bridge/test_bekci_watch.py new file mode 100644 index 0000000..8011291 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_bekci_watch.py @@ -0,0 +1,258 @@ +import concurrent.futures +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import subprocess +import sys +import threading +import time +import pytest +from bekci_watch import Frames, Ledger, MetadataFilter, WatchOwner, opaque +from native_speech_mux import NativeSpeechMux, stdio_invocation +from test_native_speech_mux import Harness + +def wait(predicate): + until=time.time()+5 + while time.time()0) + time.sleep(.1);assert mux._queue.empty() and not mux._bindings + finally:owner.close();server.close() + server=Server([frame('ready',{'cursor':'start'}),frame('notice',n,n['cursor'])]);owner=WatchOwner(mux,Ledger(tmp_path/'db2'),origin=server.origin,test_loopback=True) + try: + for t in ('A','B'): + p=meta();p['subscription']=t*40;owner.accept(p,capture(owner,t),'member',server.origin) + wait(lambda:mux._queue.qsize()==2) + assert {mux._queue.get()[1][0]['thread_id'] for _ in range(2)}=={'A','B'} + finally:owner.close();server.close() + +def test_launcher_stdio_grammar(tmp_path): + assert stdio_invocation(['-c','x=true','app-server','--analytics-default-enabled','-c','y="z"']) + assert not stdio_invocation(['exec','app-server']) + assert not stdio_invocation(['app-server','--listen','ws://127.0.0.1']) + assert not stdio_invocation(['app-server','daemon','start']) + +@pytest.mark.parametrize('active_owner', [True, False]) +def test_http_receipt_through_real_bridge_handler_to_real_pipe(tmp_path, active_owner): + """Real imported H, real HTTP primary/events, real subprocess append/approval.""" + import os + import http.client + import urllib.request + bridge=tmp_path/'bridge';bridge.mkdir() + (bridge/'bridge.json').write_text(json.dumps({'aiturk_hat':{'enabled':False},'sesle_model':False,'nokta_karsila':False,'yarim_cevap':False,'ozet_kopru':{'enabled':False},'tasma_bekcisi':False}),encoding='utf-8') + agent=tmp_path/'agent';agent.mkdir() + old=dict(os.environ);os.environ['AITURK_CODEX_BRIDGE_HOME']=str(bridge);os.environ['HERMES_HOME']=str(agent) + try: + import kopru + finally: os.environ.clear();os.environ.update(old) + h=Harness(tmp_path);h.start() + p=meta();n=notice();requests=[] + class Primary(BaseHTTPRequestHandler): + def log_message(self,*args): pass + def do_POST(self): + body=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + requests.append((self.path,self.headers.get('Authorization'),body)) + if self.path=='/v1/bekci/events': + wire=frame('ready',{'cursor':'start'})+frame('notice',n,n['cursor']) + kind='text/event-stream' + elif body.get('stream',True): + wire=frame('response.output_text.delta',{'type':'response.output_text.delta','delta':'answer'})+frame('bekci.watch',p)+b'data: [DONE]\n\n';kind='text/event-stream' + else: + wire=json.dumps({'output':[],'_bekci_watch':p}).encode();kind='application/json' + self.send_response(200);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(wire)));self.end_headers();self.wfile.write(wire) + upstream=ThreadingHTTPServer(('127.0.0.1',0),Primary);upstream.daemon_threads=True + threading.Thread(target=upstream.serve_forever,daemon=True).start();origin='http://127.0.0.1:'+str(upstream.server_port) + owner=WatchOwner(h.mux,Ledger(tmp_path/'http-ledger'),origin=origin,test_loopback=True) + kopru.WATCH_OWNER=owner if active_owner else None;kopru.MODEL_MAP['ide/test-watch']=('test-route','Sesli-Hizli-Zeki',False) + kopru.ROUTES['test-route']={'url':origin+'/v1/responses','key':'public-member','flatten_tools':False} + server=kopru.TS(('127.0.0.1',0),kopru.H);threading.Thread(target=server.serve_forever,daemon=True).start() + try: + for stream in (True,False): + conn=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=5) + conn.request('POST','/v1/responses',json.dumps({'model':'ide/test-watch','stream':stream,'prompt_cache_key':'thread-A','input':[]}),{'Content-Type':'application/json','session-id':'thread-A','thread-id':'thread-A'}) + resp=conn.getresponse();data=resp.read();conn.close() + assert resp.status==200 and b'subscription' not in data and b'_bekci_watch' not in data + if active_owner: + approval=json.loads(h.recv());speech=json.loads(h.recv()) + assert approval['method']=='item/commandExecution/requestApproval' + assert speech['method']=='seen-speech' and speech['params']=={'threadId':'thread-A','text':'correction'} + assert any(path=='/v1/bekci/events' and auth=='Bearer public-member' for path,auth,_ in requests) + else: + assert not any(path=='/v1/bekci/events' for path,_,_ in requests) + finally: + owner.close();server.shutdown();server.server_close();upstream.shutdown();upstream.server_close();kopru.WATCH_OWNER=None;h.close() + +def test_http_403_expiry_and_crash_claim_never_resend(tmp_path): + server=Server([],status=403);mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A') + owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True) + try: + owner.accept(meta(),capture(owner,'A'),'wrong-key',server.origin) + wait(lambda:len(server.requests)==1);wait(lambda:not owner.watches) + assert not mux._bindings and mux._queue.empty() + finally:owner.close();server.close() + n=notice();server=Server([frame('ready',{'cursor':'start'}),frame('notice',n,n['cursor'])]) + ledger=Ledger(tmp_path/'crash');p=meta();scope=opaque('public-watch','member',p['subscription']);assert ledger.claim(scope,'one') + owner=WatchOwner(mux,Ledger(tmp_path/'crash'),origin=server.origin,test_loopback=True) + try: + p['expires_at']=(time.time()+.4)*1000 + owner.accept(p,capture(owner,'A'),'member',server.origin) + wait(lambda:owner.ledger.cursor(scope)==n['cursor']);wait(lambda:not owner.watches) + assert mux._queue.empty() + finally:owner.close();server.close() + +def test_receipt_cannot_move_to_other_thread_or_generation(tmp_path): + server=Server([frame('ready',{'cursor':'start'})]);mux=NativeSpeechMux(['unused'],observer=True) + started(mux,'A');started(mux,'B');owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True);p=meta() + try: + assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='subscribing' + assert owner.accept(p,capture(owner,'B'),'member',server.origin)=='receipt_replay_mismatch' + mux._observe_client({'method':'thread/unsubscribe','params':{'threadId':'A'}}) + wait(lambda:not owner.watches);started(mux,'A') + assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='receipt_replay_mismatch' + finally:owner.close();server.close() + +@pytest.mark.parametrize('seconds', [16*60, 60*60, 24*60*60, 24*60*60+30]) +def test_protocol_lifetime_receipt_admitted(tmp_path, seconds): + server=Server([frame('ready', {'cursor':'start'})]) + mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A') + owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True) + try: + p=meta();p['expires_at']=(time.time()+seconds)*1000 + assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='subscribing' + wait(lambda:len(server.requests)==1) + wait(lambda:bool(mux._bindings)) + assert owner.watches + finally: owner.close();server.close() + +@pytest.mark.parametrize('expiry', [0, -1, float('inf'), float('-inf'), float('nan'), True, False, 'infinite', None, 10**100]) +def test_invalid_or_unbounded_expiry_rejected(tmp_path, expiry): + mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A') + owner=WatchOwner(mux,Ledger(tmp_path/'db')) + p=meta();p['expires_at']=expiry + assert owner.accept(p,capture(owner,'A'),'member','https://ai.turkservis.online/v1/responses')=='invalid_receipt' + assert not owner.watches + +def test_receipt_beyond_protocol_and_skew_rejected(tmp_path): + mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A') + owner=WatchOwner(mux,Ledger(tmp_path/'db')) + p=meta();p['expires_at']=(time.time()+24*60*60+120)*1000 + assert owner.accept(p,capture(owner,'A'),'member','https://ai.turkservis.online/v1/responses')=='invalid_receipt' + + +@pytest.mark.parametrize('target', ['Other-Model', 'sesli-hizli-zeki', 'Sesli-Hizli-Zeki-extra']) +@pytest.mark.parametrize('json_response', [False, True]) +@pytest.mark.parametrize('jev', [False, True]) +def test_actual_handler_nonvoice_preserves_response_pipeline(tmp_path, monkeypatch, target, json_response, jev): + import http.client + import kopru + from unittest.mock import Mock + # Exceed the sideband frame cap: other models must never enter its buffer. + wire=(json.dumps({'output':[], 'payload':'x'*270000}).encode() if json_response + else frame('response.output_text.delta', {'delta':'x'*270000})) + class Primary(BaseHTTPRequestHandler): + def log_message(self,*args): pass + def do_POST(self): + self.rfile.read(int(self.headers['Content-Length'])) + self.send_response(200) + self.send_header('Content-Type','application/json' if json_response else 'text/event-stream') + self.send_header('Content-Length',str(len(wire)));self.end_headers();self.wfile.write(wire) + upstream=ThreadingHTTPServer(('127.0.0.1',0),Primary) + threading.Thread(target=upstream.serve_forever,daemon=True).start() + owner=Mock();monkeypatch.setattr(kopru,'WATCH_OWNER',owner) + monkeypatch.setitem(kopru.MODEL_MAP,'ide/scope-proof',('aiturk-ide',target,None)) + monkeypatch.setitem(kopru.ROUTES,'aiturk-ide',{'url':f'http://127.0.0.1:{upstream.server_port}/v1/responses','key':'scope-member','flatten_tools':jev}) + monkeypatch.setattr(kopru.jev_denetim,'anahtar',lambda:'test-key' if jev else '') + wrapped=Mock(side_effect=AssertionError('nonvoice entered watch wrapper')) + monkeypatch.setattr(kopru,'WatchResponse',wrapped) + shortcut=Mock(side_effect=AssertionError('nonvoice entered JSON shortcut')) + monkeypatch.setattr(kopru.H,'_send_json',shortcut) + calls=[] + original_stream=kopru.H._stream + def stream(self,resp,*args,**kwargs): + calls.append('stream') + return original_stream(self,resp,*args,**kwargs) + def denetim(self,resp,tag,rewriter,izle,istek,sira,ac,*,hedef): + # Verify original upstream object/body reaches Jev, not a watch wrapper. + calls.append('jev');assert hedef==target and sira + data=resp.read();resp.close();assert data==wire + self.send_response(200);self.send_header('Content-Length',str(len(data))) + self.end_headers();self.wfile.write(data) + monkeypatch.setattr(kopru.H,'_stream',stream) + monkeypatch.setattr(kopru.H,'_denetimli_akis',denetim) + server=kopru.TS(('127.0.0.1',0),kopru.H) + threading.Thread(target=server.serve_forever,daemon=True).start() + try: + conn=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=5) + conn.request('POST','/v1/responses',json.dumps({'model':'ide/scope-proof','stream':not json_response,'input':[]}),{'Content-Type':'application/json'}) + response=conn.getresponse();data=response.read();conn.close() + assert response.status==200 and data==wire + assert calls==(['jev'] if jev else ['stream']) + wrapped.assert_not_called();shortcut.assert_not_called();owner.accept.assert_not_called() + finally: + server.shutdown();server.server_close();upstream.shutdown();upstream.server_close() diff --git a/apps/desktop/assets/codex-bridge/test_combo_tekrar.py b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py index d98bdb0..bfde876 100644 --- a/apps/desktop/assets/codex-bridge/test_combo_tekrar.py +++ b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py @@ -27,7 +27,7 @@ class ComboNotResent(unittest.TestCase): def setUp(self): self.upstream = Mock() self.env = { - "json": json, "re": __import__("re"), "os": __import__("os"), "http": __import__("http.client"), + "WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "os": __import__("os"), "http": __import__("http.client"), "time": types.SimpleNamespace(sleep=lambda s: None), "urllib": types.SimpleNamespace( request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=self.upstream), diff --git a/apps/desktop/assets/codex-bridge/test_denetim_ek.py b/apps/desktop/assets/codex-bridge/test_denetim_ek.py index 2bbad18..df72ba5 100644 --- a/apps/desktop/assets/codex-bridge/test_denetim_ek.py +++ b/apps/desktop/assets/codex-bridge/test_denetim_ek.py @@ -164,7 +164,7 @@ class Ozet(unittest.TestCase): class Akis(unittest.TestCase): env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"), - {"json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere}) + {"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere}) def events(self, raw): return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")] @@ -256,7 +256,7 @@ class Zincir(unittest.TestCase): "claude_sonu_duzelt", "ek_model_mu", "yonlendirilen") + [method("_responses"), method("_model_komutu"), method("_ozgun")], { "gecit_modelleri": lambda: [], - "json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"), + "WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"), "time": types.SimpleNamespace(sleep=lambda s: None, time=time.time), "urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()), error=urllib.error), @@ -389,6 +389,22 @@ class Zincir(unittest.TestCase): self.assertEqual(sent["instructions"], req["instructions"]) self.assertEqual(sent["input"][0], req["input"][0]) + def test_nonvoice_json_still_reaches_original_jev_with_upstream_identity(self): + self.env["ROUTES"]["aiturk-ide"] = { + "url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True} + self.env["MODEL_MAP"]["ide/scope"] = ("aiturk-ide", "Other-Model", None) + response = Mock(status=200, headers={"Content-Type": "application/json"}) + self.env["urllib"].request.urlopen.return_value = response + watch = self.env["WatchResponse"] = Mock(side_effect=AssertionError("watch scope escaped")) + fake = Sahte(); fake._denetimli_akis = Mock() + with patch.object(self.env["jev_denetim"], "anahtar", return_value="test-only"): + fake._responses(1, "/responses", json.dumps({"model":"ide/scope", "input":[user("task")]}).encode()) + fake._denetimli_akis.assert_called_once() + self.assertIs(fake._denetimli_akis.call_args.args[0], response) + self.assertEqual(fake._denetimli_akis.call_args.kwargs, {"hedef":"Other-Model"}) + response.read.assert_not_called() + watch.assert_not_called(); fake._send_json.assert_not_called(); fake._stream.assert_not_called() + def test_combo_dropped_after_sending_is_not_resent_but_a_single_model_is(self): import http.client self.external(http.client.RemoteDisconnected("closed")) @@ -438,7 +454,7 @@ class Govde(unittest.TestCase): class Liste(unittest.TestCase): def env(self, **extra): - env = {"json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {}, + env = {"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {}, "decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False, "SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(), "RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid", diff --git a/apps/desktop/assets/codex-bridge/test_native_speech_mux.py b/apps/desktop/assets/codex-bridge/test_native_speech_mux.py new file mode 100644 index 0000000..97fd5d6 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_native_speech_mux.py @@ -0,0 +1,358 @@ +"""Real pipes/processes and real loopback HTTP; no native runtime activation.""" +import concurrent.futures +import http.client +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import threading +import time + +import native_speech_mux as muxmod + + +# Fake engine is a separate process speaking complete JSONL, not mocked Popen. +ENGINE = r''' +import json, os, sys, time +if '--probe' in sys.argv: + print(json.dumps({'argv':sys.argv[1:], 'cwd':os.getcwd(), 'marker':os.getenv('MUX_TEST_MARKER')})) + sys.stderr.write('original-stderr\n') + sys.exit(23) +for raw in sys.stdin.buffer: + try: m=json.loads(raw) + except ValueError: + sys.stdout.buffer.write(raw); sys.stdout.buffer.flush(); continue + method=m.get('method') + def emit(v): + sys.stdout.buffer.write(json.dumps(v).encode()+b'\n'); sys.stdout.buffer.flush() + if method=='thread/start': + emit({'id':m['id'],'result':{'thread':{'id':'thread-A'}}}) + elif method=='thread/realtime/start': + emit({'method':'thread/realtime/started','params':{'threadId':'thread-A','realtimeSessionId':'rt'}}) + elif method=='thread/realtime/stop': + emit({'method':'thread/realtime/closed','params':{'threadId':'thread-A','reason':None}}) + elif method=='error-event': + emit({'method':'thread/realtime/error','params':{'threadId':'thread-A','message':'failed'}}) + elif method=='thread/realtime/appendSpeech': + emit({'method':'item/commandExecution/requestApproval','id':m['id'],'params':{'command':'test'}}) + emit({'method':'seen-speech','params':m['params']}) + emit({'id':m['id'], 'error':{'code':-1,'message':'no'}} if m['params']['text']=='rpc-error' else {'id':m['id'],'result':{}}) + elif method=='delay': + time.sleep(.25) + sys.stdout.buffer.write(raw); sys.stdout.buffer.flush() + elif method=='pause': + emit({'method':'paused'}) + while not os.path.exists(m['params']['release']): time.sleep(.01) + emit({'method':'released'}) + elif method=='exit': + sys.exit(17) + else: + sys.stdout.buffer.write(raw); sys.stdout.buffer.flush() +sys.stdout.buffer.write(b'{"method":"saw-eof"}\n'); sys.stdout.buffer.flush() +sys.stderr.write('engine-eof\n') +sys.exit(19) +''' + + +class Harness: + def __init__(self, tmp_path): + engine = tmp_path / 'engine.py' + engine.write_text(ENGINE) + self.mux = muxmod.NativeSpeechMux([sys.executable, '-u', str(engine), 'app-server'], observer=True) + ir, iw = os.pipe() + outr, outw = os.pipe() + self.source = os.fdopen(ir, 'rb', buffering=0) + self.input = os.fdopen(iw, 'wb', buffering=0) + self.target = os.fdopen(outw, 'wb', buffering=0) + self.output = os.fdopen(outr, 'rb') + self.lines = queue.Queue() + self.code = None + self.error = None + self.url, self.token = self.mux.start_inlet() + def run(): + try: + self.code = self.mux.run(self.source, self.target) + except BaseException as e: + self.error = e + finally: + self.target.close() + self.thread = threading.Thread(target=run, daemon=True) + self.thread.start() + def collect(): + for raw in self.output: + self.lines.put(raw) + self.collector = threading.Thread(target=collect, daemon=True) + self.collector.start() + + def send(self, value): + raw = value if isinstance(value, bytes) else json.dumps(value).encode()+b'\n' + self.input.write(raw) + + def recv(self): + return self.lines.get(timeout=8) + + def http(self, path, body, token=None): + conn = http.client.HTTPConnection('127.0.0.1', int(self.url.rsplit(':', 1)[1]), timeout=5) + conn.request('POST', path, json.dumps(body), {'Authorization':'Bearer '+(token if token is not None else self.token)}) + response = conn.getresponse() + data = response.read() + conn.close() + return response.status, json.loads(data) if response.status == 200 else data + + def start(self): + self.send({'id':1, 'method':'thread/start','params':{}}) + assert json.loads(self.recv())['result']['thread']['id'] == 'thread-A' + assert self.mux.snapshot()['threads']['thread-A']['active'] is False + self.send({'id':2,'method':'thread/realtime/start','params':{'threadId':'thread-A'}}) + assert json.loads(self.recv())['method'] == 'thread/realtime/started' + return self.bind() + + def bind(self): + generation = self.mux.snapshot()['threads']['thread-A']['generation'] + b = muxmod.Binding('tenant','bekci','rev',self.mux.connection_id,'thread-A',generation) + assert self.mux.bind(b) + return self.notice(generation) + + def notice(self, generation=1, **changes): + now = time.time() + return dict(notice_id='notice-1',tenant_id='tenant',bekci_session_id='bekci',history_revision='rev', + connection_id=self.mux.connection_id,thread_id='thread-A',expected_generation=generation, + issued_at=now,expires_at=now+30,text='düzeltme', **changes) + + def close(self): + self.input.close() + self.thread.join(10) + if self.thread.is_alive(): + self.mux._process.kill() + self.thread.join(5) + raise AssertionError('mux failed to exit after EOF') + self.collector.join(3) + self.source.close() + self.output.close() + assert self.error is None + + +def test_native_notice_rpc_and_approval_are_separate(tmp_path): + h = Harness(tmp_path) + try: + notice = h.start() + assert h.http('/notice', notice, 'wrong')[0] == 401 + assert h.http('/notice', notice)[1]['status'] == 'queued' + approval = json.loads(h.recv()) + assert approval['method'] == 'item/commandExecution/requestApproval' + assert approval['id'].startswith('bekci-speech-') + assert json.loads(h.recv()) == {'method':'seen-speech','params':{'threadId':'thread-A','text':'düzeltme'}} + answer = (json.dumps({'id':approval['id'],'result':{'decision':'decline'}}, separators=(', ', ': '))+'\r\n').encode() + h.send(answer) + assert h.recv() == answer + assert h.http('/notice', notice)[1]['status'] == 'rpc_accepted' + assert h.lines.empty() # native own-result never leaked to desktop + assert h.http('/rpc', {'method':'turn/start'})[0] == 404 + assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'queued' + h.recv(); h.recv() + h.send(b'{"method":"barrier"}\n') + assert h.recv() == b'{"method":"barrier"}\n' + assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'rpc_error' + finally: + h.close() + assert h.code == 19 + assert json.loads(h.recv())['method'] == 'saw-eof' + + +def test_unbound_schema_ttl_tenant_generation_and_restart(tmp_path): + h = Harness(tmp_path) + try: + n = h.notice() + assert h.http('/notice', n)[1]['status'] == 'binding_not_established' + n = h.start() + for delta, status in [({'method':'turn/start'},'invalid_schema'), + ({'expected_generation':True},'invalid_schema'), + ({'expires_at':time.time()-1},'expired_or_invalid_ttl'), + ({'issued_at':float('nan')},'expired_or_invalid_ttl'), + ({'tenant_id':'other'},'binding_not_established'), + ({'history_revision':'other'},'binding_mismatch'), + ({'expected_generation':2},'binding_mismatch'), + ({'connection_id':'other'},'binding_mismatch')]: + assert h.http('/notice', {**n, **delta})[1]['status'] == status + h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}}) + assert json.loads(h.recv())['method'] == 'thread/realtime/closed' + assert h.http('/notice', n)[1]['status'] == 'binding_not_established' + h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}}) + h.recv() + fresh = h.bind() + assert fresh['expected_generation'] == n['expected_generation']+1 + assert h.http('/notice', n)[1]['status'] == 'binding_mismatch' + h.send({'method':'error-event'}) + h.recv() + assert h.http('/notice', fresh)[1]['status'] == 'binding_not_established' + finally: + h.close() + + +def test_concurrent_notices_and_raw_backpressure_order(tmp_path): + h = Harness(tmp_path) + try: + n = h.start() + h.send({'method':'delay'}) + records = [(json.dumps({'method':'echo','i':i,'padding':'x'*16000})+'\r\n').encode() for i in range(90)] + producer = threading.Thread(target=lambda: [h.send(r) for r in records], daemon=True) + producer.start() + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda i: h.http('/notice',{**n,'notice_id':str(i)})[1]['status'], range(12))) + assert set(results) <= {'queued','busy'} + accepted = results.count('queued') + echoes = [] + speech = 0 + approvals = 0 + for _ in range(1 + len(records) + accepted*2): + raw = h.recv() + method = json.loads(raw).get('method') + if method == 'echo': echoes.append(raw) + elif method == 'seen-speech': speech += 1 + elif method == 'item/commandExecution/requestApproval': approvals += 1 + producer.join(5) + assert not producer.is_alive() + assert echoes == records + assert speech == approvals == accepted + assert h.mux._queue.maxsize == 32 + finally: + h.close() + + +def test_fragmented_oversized_jsonl_and_partial_eof_preserved(tmp_path): + h = Harness(tmp_path) + try: + h.start() + raw = b'{ "method":"huge", "value":"' + b'x'*200000 + b'"}\r\n' + producer = threading.Thread(target=lambda: h.send(raw),daemon=True) + producer.start() + assert h.recv() == raw + producer.join(5) + assert h.mux.snapshot()['injection_disabled'] + h.send(b'{"partial":true}') + finally: + h.close() + # Engine echoes an unterminated record, then its EOF notification: byte exact. + assert h.recv() == b'{"partial":true}{"method":"saw-eof"}\n' + assert h.code == 19 + + +def test_non_appserver_passthrough_argv_env_cwd_stderr_exit(tmp_path, monkeypatch): + engine = tmp_path / 'engine.py' + engine.write_text(ENGINE) + module = Path(muxmod.__file__).resolve() + monkeypatch.setenv('MUX_TEST_MARKER','inherited') + args = [sys.executable,str(module),sys.executable,'--','-u',str(engine),'--probe','space arg','app-server'] + p = subprocess.run(args,cwd=tmp_path,capture_output=True,timeout=10) + assert p.returncode == 23 + result = json.loads(p.stdout) + assert result == {'argv':['--probe','space arg','app-server'],'cwd':str(tmp_path),'marker':'inherited'} + direct = subprocess.run([sys.executable, '-u', str(engine), '--probe', 'space arg', 'app-server'], + cwd=tmp_path, capture_output=True, timeout=10) + assert (p.stdout, p.stderr, p.returncode) == (direct.stdout, direct.stderr, direct.returncode) + + +def test_engine_exit_without_desktop_eof(tmp_path): + h = Harness(tmp_path) + try: + h.send({'method':'exit'}) + h.thread.join(5) + assert not h.thread.is_alive() + assert h.code == 17 + assert h.mux.snapshot()['closed'] + finally: + h.close() + + +def test_owned_namespace_collision_disables_injection_but_forwards(tmp_path): + h = Harness(tmp_path) + try: + n = h.start() + raw = json.dumps({'id':h.mux._prefix+'collision','method':'echo'}).encode()+b'\n' + h.send(raw) + assert h.recv() == raw + assert h.http('/notice', n)[1]['status'] == 'injection_disabled' + finally: + h.close() + + +def test_queued_notice_revalidated_after_observed_stop(tmp_path): + h = Harness(tmp_path) + release = tmp_path / 'release' + try: + n = h.start() + h.send({'method':'pause','params':{'release':str(release)}}) + assert json.loads(h.recv())['method'] == 'paused' + # Fill the OS pipe while the real engine is paused; leave bounded queue + # capacity for the notice and stop record behind it. + for i in range(8): + h.send({'method':'echo','i':i,'padding':'x'*32000}) + assert h.http('/notice', n)[1]['status'] == 'queued' + h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}}) + deadline = time.monotonic()+5 + while h.mux.snapshot()['threads']['thread-A']['active'] and time.monotonic() < deadline: + time.sleep(.01) + assert not h.mux.snapshot()['threads']['thread-A']['active'] + release.touch() + methods = [json.loads(h.recv())['method'] for _ in range(10)] + assert methods == ['released'] + ['echo']*8 + ['thread/realtime/closed'] + assert h.mux._receipts[('tenant','bekci','notice-1')]['status'] == 'binding_not_established' + assert h.lines.empty() + finally: + release.touch() + h.close() + +def test_real_request_correlation_and_opaque_receipt_contract(tmp_path): + h = Harness(tmp_path) + try: + n = h.start() + adapter = muxmod.WatchReceiptAdapter(h.mux) + headers = {'Session-Id':'thread-A','Thread-Id':'thread-A'} + correlation = adapter.capture(headers, {'prompt_cache_key':'thread-A'}) + assert correlation.thread_id == 'thread-A' + assert adapter.capture({**headers,'Thread-Id':'other'}, {'prompt_cache_key':'thread-A'}) is None + assert adapter.capture(headers, {}) is None + assert adapter.accept('bekci.watch', {}, correlation) == 'backend_contract_not_established' + # Internal contract fixture, explicitly NOT a claim about backend wire format. + receipt = muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()+60) + adapter.decode = lambda payload: payload + adapter.resolve = lambda handle: receipt + assert adapter.accept('bekci.watch','opaque',correlation) == 'bound' + assert adapter.accept('_bekci_watch','opaque',correlation) == 'bound' + assert adapter.accept('other','opaque',correlation) == 'unsupported_metadata_transport' + assert adapter.notice('unbound',n)['status'] == 'binding_not_established' + assert adapter.notice('opaque',{**n,'tenant_id':'other'})['status'] == 'binding_mismatch' + assert adapter.notice('opaque',n)['status'] == 'queued' + assert json.loads(h.recv())['method'] == 'item/commandExecution/requestApproval' + assert json.loads(h.recv())['method'] == 'seen-speech' + h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}}); h.recv() + h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}}); h.recv() + assert adapter.accept('bekci.watch','opaque',correlation) == 'stale_generation' + fresh = adapter.capture(headers, {'prompt_cache_key':'thread-A'}) + adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','wrong',time.time()+60) + assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired' + adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()-1) + assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired' + finally: + h.close() + + +def test_verified_original_launcher_fallback_actual_process(tmp_path): + import hashlib + executable = str(Path(sys.executable).resolve()) + with open(executable,'rb') as f: + digest = hashlib.file_digest(f,'sha256').hexdigest() + assert muxmod.verified_original(executable,digest) == executable + import pytest + with pytest.raises(ValueError): muxmod.verified_original(executable,'0'*64) + with pytest.raises(ValueError): muxmod.verified_original('python.exe',digest) + # A value that happens to say app-server must not trigger observer/attachment. + marker = tmp_path/'original-ran' + code = 'from pathlib import Path; import sys; Path(sys.argv[1]).write_text("ok"); sys.exit(21)' + def forbidden(adapter): + raise AssertionError('not an app-server invocation') + assert muxmod.launch_original(executable,digest,['-c',code,str(marker),'app-server'],attach=forbidden) == 21 + assert marker.read_text() == 'ok' + diff --git a/apps/desktop/assets/codex-bridge/test_native_watch_cleanup.py b/apps/desktop/assets/codex-bridge/test_native_watch_cleanup.py new file mode 100644 index 0000000..6f9bb87 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_native_watch_cleanup.py @@ -0,0 +1,116 @@ +"""Windows owned-process lifetime tests; no installed process is terminated.""" +import ctypes +from ctypes import wintypes as w +import hashlib +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import threading +import time +import pytest +from prepare_native_watch import prepare + +pytestmark = pytest.mark.skipif(sys.platform != 'win32', reason='Windows executable lifecycle') + +def descendants(parent): + class Entry(ctypes.Structure): + _fields_=[('size',w.DWORD),('usage',w.DWORD),('pid',w.DWORD),('heap',ctypes.c_size_t),('module',w.DWORD),('threads',w.DWORD),('parent',w.DWORD),('priority',w.LONG),('flags',w.DWORD),('exe',w.WCHAR*260)] + k=ctypes.WinDLL('kernel32',use_last_error=True) + k.CreateToolhelp32Snapshot.restype=w.HANDLE + k.Process32FirstW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)] + k.Process32NextW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)] + k.CloseHandle.argtypes=[w.HANDLE] + snap=k.CreateToolhelp32Snapshot(2,0);entry=Entry();entry.size=ctypes.sizeof(entry);rows=[] + try: + more=k.Process32FirstW(snap,ctypes.byref(entry)) + while more: + rows.append((entry.pid,entry.parent));more=k.Process32NextW(snap,ctypes.byref(entry)) + finally:k.CloseHandle(snap) + owned={parent} + while True: + children={pid for pid,ppid in rows if ppid in owned} + if children <= owned: return owned-{parent} + owned |= children + + +def assert_abrupt_cleanup(p, expected_min): + k=ctypes.WinDLL('kernel32',use_last_error=True) + k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE + k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD] + k.CloseHandle.argtypes=[w.HANDLE] + ids=descendants(p.pid) + assert len(ids)>=expected_min, ids + handles=[k.OpenProcess(0x100000,False,pid) for pid in ids] + assert all(handles) + try: + p.kill();p.wait(timeout=5) + assert all(k.WaitForSingleObject(h,5000)==0 for h in handles), ids + finally: + for h in handles:k.CloseHandle(h) + return len(ids) + + +def line(p): + q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start() + return q.get(timeout=20) + + +def candidate(tmp_path, original, digest): + return prepare(Path(__file__).parent,tmp_path/'candidate',original,digest,Path(sys.executable),tmp_path/'bridge',tmp_path/'agent') + + +def test_abrupt_bootstrap_exit_kills_owned_python_and_grandchild(tmp_path): + exe=Path(sys.executable) + launcher=candidate(tmp_path,exe,hashlib.sha256(exe.read_bytes()).hexdigest()) + script='import subprocess,sys,time; p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(120)"]); print(p.pid,flush=True); time.sleep(120)' + p=subprocess.Popen([str(launcher),'-u','-c',script],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + assert int(line(p))>0 + assert_abrupt_cleanup(p,3) + finally: + if p.poll() is None:p.kill();p.wait(timeout=5) + p.stdin.close();p.stdout.close();p.stderr.close() + + +def test_pinned_original_initialize_and_abrupt_cleanup(tmp_path): + # Explicit provisioned evidence, never discovers or kills an existing app. + manifest_path=os.environ.get('NATIVE_WATCH_TEST_MANIFEST') + if not manifest_path: pytest.skip('requires explicit pinned original candidate') + manifest=json.loads(Path(manifest_path).read_text(encoding='utf-8-sig')) + launcher=candidate(tmp_path,Path(manifest['original']),manifest['original_sha256']) + p=subprocess.Popen([str(launcher),'-c','model_provider="openai"','app-server','--analytics-default-enabled'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + p.stdin.write(json.dumps({'id':1,'method':'initialize','params':{'clientInfo':{'name':'owned-cleanup-proof','version':'1.0'},'capabilities':{'experimentalApi':True}}}).encode()+b'\n');p.stdin.flush() + response=json.loads(line(p));assert response.get('id')==1 and 'result' in response,response + assert_abrupt_cleanup(p,2) + finally: + if p.poll() is None:p.kill();p.wait(timeout=5) + p.stdin.close();p.stdout.close();p.stderr.close() + +def test_compiled_launcher_fallback(tmp_path): + source=Path(__file__).parent + import hashlib + exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest() + launcher=prepare(source,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent') + args=['-c','import json,sys,os; print(json.dumps([sys.argv[1:],os.getcwd()])); sys.stderr.write("stderr\\n"); sys.exit(23)','space arg','quote"arg','trailing\\'] + direct=subprocess.run([str(exe),*args],cwd=tmp_path,capture_output=True) + wrapped=subprocess.run([str(launcher),*args],cwd=tmp_path,capture_output=True) + assert (wrapped.returncode,wrapped.stdout,wrapped.stderr)==(direct.returncode,direct.stdout,direct.stderr) + manifest=launcher.with_suffix('.json');data=json.loads(manifest.read_text(encoding='utf-8'));data['original_sha256']='0'*64;manifest.write_text(json.dumps(data),encoding='utf-8') + assert subprocess.run([str(launcher),'--version'],capture_output=True).returncode==78 + +def test_executable_flushes_interactive_stdin_before_eof(tmp_path): + import hashlib,queue + exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest() + launcher=prepare(Path(__file__).parent,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent') + p=subprocess.Popen([str(launcher),'-u','-c','import sys; print(sys.stdin.readline().strip(),flush=True); sys.stdin.read()'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start() + try: + p.stdin.write(b'live-before-eof\n');p.stdin.flush() + assert q.get(timeout=5).strip()==b'live-before-eof' + finally: + p.stdin.close();p.wait(timeout=5) + diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index cf891b2..4dc75d8 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama 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 = '9' +export const CODEX_BRIDGE_VERSION = '10' 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' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8bf2024..83b87f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.29", + "version": "1.0.0-beta.30", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { @@ -306,6 +306,7 @@ "filter": [ "**/*.py", "**/*.pyd", + "native-watch.exe", "**/LICENSE", "!test_*.py" ] diff --git a/apps/desktop/scripts/before-pack.mjs b/apps/desktop/scripts/before-pack.mjs index 20045cf..a390a90 100644 --- a/apps/desktop/scripts/before-pack.mjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -57,6 +57,7 @@ * - electronPlatformName: 'win32' | 'darwin' | 'linux' * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ +import { buildNativeWatchLauncher } from './build-native-watch-launcher.mjs' import { existsSync, rmSync, renameSync } from 'node:fs' import path from 'node:path' import { Arch } from 'electron-builder' @@ -111,6 +112,7 @@ export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') } export default async function beforePack(context) { + if (context?.electronPlatformName === 'win32') buildNativeWatchLauncher() const appOutDir = context && context.appOutDir const platformName = context && context.electronPlatformName try { diff --git a/apps/desktop/scripts/build-native-watch-launcher.mjs b/apps/desktop/scripts/build-native-watch-launcher.mjs new file mode 100644 index 0000000..21ef16e --- /dev/null +++ b/apps/desktop/scripts/build-native-watch-launcher.mjs @@ -0,0 +1,30 @@ +/** Build the Windows artifact from source on Windows or the Linux/Wine CI host. */ +import { execFileSync } from 'node:child_process' +import { rmSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const sourceHome = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets/codex-bridge') +export function compilerCommand({ platform = process.platform, env = process.env, source, output }) { + const args = ['/nologo', '/target:exe', '/platform:anycpu', '/reference:System.Web.Extensions.dll', `/out:${output}`, source] + if (platform === 'win32') { + return { command: path.win32.join(env.SystemRoot || 'C:/Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), args } + } + if (platform === 'linux') return { command: 'mcs', args } + throw new Error(`Windows launcher compilation is not configured on ${platform}`) +} + +export function buildNativeWatchLauncher({ home = sourceHome, ...options } = {}) { + const output = path.join(home, 'native-watch.exe') + const invocation = compilerCommand({ ...options, source: path.join(home, 'NativeWatchBootstrap.cs'), output }) + // A missing compiler or broken source must fail packaging, never reuse an old EXE. + rmSync(output, { force: true }) + execFileSync(invocation.command, invocation.args, { windowsHide: true, stdio: 'inherit' }) + const bytes = readFileSync(output) + if (bytes.length < 64 || bytes.toString('ascii', 0, 2) !== 'MZ' || bytes.toString('ascii', bytes.readUInt32LE(60), bytes.readUInt32LE(60) + 4) !== 'PE\0\0') { + rmSync(output, { force: true }) + throw new Error('Native watch compiler did not produce a Windows PE executable') + } + return output +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) buildNativeWatchLauncher() diff --git a/apps/desktop/scripts/build-native-watch-launcher.test.mjs b/apps/desktop/scripts/build-native-watch-launcher.test.mjs new file mode 100644 index 0000000..b34bb24 --- /dev/null +++ b/apps/desktop/scripts/build-native-watch-launcher.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, copyFileSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { compilerCommand, buildNativeWatchLauncher } from './build-native-watch-launcher.mjs' + +const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +test('Windows and Linux hosts build the same Windows source with the required assembly', () => { + const input = { source: 'source with spaces.cs', output: 'output with spaces.exe' } + const win = compilerCommand({ ...input, platform: 'win32', env: { SystemRoot: 'D:/Windows' } }) + const linux = compilerCommand({ ...input, platform: 'linux' }) + assert.equal(win.command, 'D:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe') + assert.equal(linux.command, 'mcs') + assert.deepEqual(win.args, linux.args) + assert.ok(linux.args.includes('/reference:System.Web.Extensions.dll')) + assert.equal(linux.args.at(-1), input.source) + assert.throws(() => compilerCommand({ ...input, platform: 'darwin' }), /not configured/) +}) + +test('real host compiler builds PE from source, and broken source fails without stale artifact', () => { + // Inside the source workspace, including on the mounted Linux CI fixture. + const home = mkdtempSync(path.join(desktop, '.native-watch-test-')) + try { + copyFileSync(path.join(desktop, 'assets/codex-bridge/NativeWatchBootstrap.cs'), path.join(home, 'NativeWatchBootstrap.cs')) + const exe = buildNativeWatchLauncher({ home }) + const first = readFileSync(exe) + assert.equal(first.toString('ascii', 0, 2), 'MZ') + writeFileSync(exe, 'stale artifact') + buildNativeWatchLauncher({ home }) + assert.equal(readFileSync(exe).toString('ascii', 0, 2), 'MZ') + writeFileSync(path.join(home, 'NativeWatchBootstrap.cs'), 'invalid C# source') + assert.throws(() => buildNativeWatchLauncher({ home })) + assert.equal(existsSync(exe), false) + } finally { rmSync(home, { recursive: true, force: true }) } +}) diff --git a/package-lock.json b/package-lock.json index 11505b2..c617621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.29", + "version": "1.0.0-beta.30", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",