From 1d406ed3084eee15ae68f5a08a4047fc927c36f3 Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 14:11:54 +0000 Subject: [PATCH 1/6] setup: per-harness consent, copyable connection details, proxy rotator fixes Consent (setup): - one y/n question per detected harness ("Configure ZCode as a provider with its supported models in ? [y/n]"); only y configures, n skips and never deletes; without a terminal undecided harnesses are skipped unless selected with --harness or ZCODE_KIT_HARNESSES (none skips all; unknown ids are errors) - decisions stored per harness under generated/harness-choices/ through the setup transaction (rollback removes them); update and doctor --fix refresh only consented or kit-owned integrations; --reask asks again - one prompter for all questions (typed-ahead answers stay in order), Ctrl-C aborts with exit 130 and applies only the answers given - a failing harness no longer takes the others down: its partial writes are undone via transaction savepoints, setup exits 1, installers warn and continue - MCP bridge registration follows consent; doctor reports declined harnesses as SKIP instead of FAIL Connection details (manual client setup): - setup, proxy start (also when already running) and proxy status print the real base URLs, the local key and the model ids of the verified own proxy; a stopped proxy is labelled as configuration only, a foreign listener withholds the details - the full key is shown only on an interactive terminal (stdin and stdout TTY, not CI); logs, pipes, JSON and installer logs get a redaction marker; status never creates or rotates the key Proxy: - account rotator: non-inference operations no longer move the sticky account; the effective identity no longer depends on userId - ordered transport: socket open is abortable; SNI only for hostnames - upstream errors: no quota retry after bytes reached the client - captcha verify header masked in debug logs Docs: README (+ de/es/ja/zh-CN), harness guides (+ translations), proxy README intros, CLI usage; tests for consent, connection details, transaction savepoints and the proxy fixes. --- README.de.md | 42 +- README.es.md | 42 +- README.ja.md | 42 +- README.md | 42 +- README.zh-CN.md | 42 +- cli/account-setup.mjs | 17 +- cli/adapters/aider.mjs | 5 + cli/adapters/claude-code.mjs | 5 + cli/adapters/cline.mjs | 5 + cli/adapters/codex.mjs | 5 + cli/adapters/continue.mjs | 10 + cli/adapters/goose.mjs | 7 + cli/adapters/kilo-code.mjs | 5 + cli/adapters/omp.mjs | 10 + cli/adapters/opencode.mjs | 12 + cli/adapters/pi.mjs | 12 + cli/connection-details.mjs | 73 +++ cli/harness-consent.mjs | 289 +++++++++ cli/setup-output.mjs | 6 + cli/zcode-kit.mjs | 232 +++++-- harnesses/README.de.md | 13 +- harnesses/README.es.md | 13 +- harnesses/README.ja.md | 13 +- harnesses/README.md | 45 +- harnesses/README.zh-CN.md | 12 +- install.ps1 | 8 +- install.sh | 18 +- lib/transaction.mjs | 43 ++ package.json | 2 +- proxy/zcode-proxy-manager.mjs | 77 ++- setup.mjs | 5 +- tests/connection-details.test.mjs | 176 +++++ tests/harness-consent.test.mjs | 603 ++++++++++++++++++ tests/setup-regressions.test.mjs | 6 +- zcode-proxy-src/README.de.md | 4 +- zcode-proxy-src/README.es.md | 3 +- zcode-proxy-src/README.ja.md | 4 +- zcode-proxy-src/README.md | 2 +- zcode-proxy-src/README.zh-CN.md | 4 +- .../src/auth/account-rotator.test.ts | 32 + zcode-proxy-src/src/auth/account-rotator.ts | 16 +- zcode-proxy-src/src/proxy/handler.ts | 4 +- .../src/proxy/ordered-transport.test.ts | 29 + .../src/proxy/ordered-transport.ts | 33 +- .../src/proxy/upstream-errors-pool.test.ts | 23 + zcode-proxy-src/src/proxy/upstream-errors.ts | 7 +- 46 files changed, 1926 insertions(+), 172 deletions(-) create mode 100644 cli/connection-details.mjs create mode 100644 cli/harness-consent.mjs create mode 100644 tests/connection-details.test.mjs create mode 100644 tests/harness-consent.test.mjs diff --git a/README.de.md b/README.de.md index 7ca9d60..d1fca4b 100644 --- a/README.de.md +++ b/README.de.md @@ -7,7 +7,7 @@ Nutze dein ZCode-Konto mit dem Coding-Assistenten, den du bereits verwendest. *Das Kit läuft lokal; Modellanfragen gehen an ZCode.* -Behalte deinen Coding-Assistenten. Nutze deine vorhandenen ZCode-Modelle und dein Kontingent über eine lokale Verbindung. +Behalte deinen Coding-Assistenten. Nutze deine vorhandenen ZCode-Modelle und dein Kontingent über eine lokale Verbindung. Zwei gleichwertige Wege: Das Kit richtet einen erkannten Assistenten ein, nachdem du dafür `y` geantwortet hast, oder du trägst die angezeigte Basis-URL und den API-Schlüssel selbst in einen beliebigen OpenAI- oder Anthropic-kompatiblen Client ein. **Optionale Kontorotation:** Der Installer fragt **"Do you want to activate the Account Rotator feature? [y/n]"**. Mit `y` wird der aktuelle Login übernommen; spätere Anmeldungen über `zcode-kit auth login zai` werden als zusätzliche Konten gespeichert. Eine erneute Anmeldung als bereits gespeicherter Nutzer aktualisiert normalerweise dessen Eintrag; die Dokumentation erklärt, wie Nutzer zugeordnet werden, und nennt die Ausnahmen. Später aktivieren mit `zcode-kit accounts enable`; gespeicherte Konten mit `zcode-kit accounts` anzeigen. `zcode-kit accounts health [--json]` zeigt auf Abruf je Konto ein Urteil aus Abrechnungsdaten. Das ist kein Beleg, dass Modellanfragen funktionieren; ein Konto ohne Kontingentdaten gilt nicht als gesund, und Konten werden nicht fortlaufend abgefragt. Siehe die [Dokumentation zum Account Rotator](docs/ACCOUNT_ROTATOR.de.md). @@ -22,11 +22,11 @@ Behalte deinen Coding-Assistenten. Nutze deine vorhandenen ZCode-Modelle und dei ## Schritt 2 — Kit einmal installieren -Wähle unten den Release-Installer oder npm. Der Release-Installer richtet erkannte Assistenten automatisch ein; danach ist kein separater Setup-Befehl nötig. +Wähle unten den Release-Installer oder npm. Der Release-Installer führt das Setup für dich aus; ein separater Setup-Befehl ist danach nicht nötig. Das Setup stellt pro erkanntem Assistenten eine Frage, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (mit dem Namen des Assistenten), und ändert dessen Konfiguration nur nach einem `y`; `n` überspringt ihn und lässt seine Einstellungen unverändert. Ohne Antwort im Terminal wird ein Assistent nur eingerichtet, wenn du ihn ausdrücklich auswählst (siehe unten) oder früher zugestimmt hast. -Der Installer zeigt vier nummerierte Schritte, kompakte Ergebnisse pro Assistent und eine Verbindungsprüfung. Ausführliche Setup-Ausgaben stehen in der angezeigten `install.log`; mit `ZCODE_KIT_VERBOSE=1` siehst du die vollständige Ausgabe und mit `NO_COLOR=1` einfachen Text. Bei interaktiver Installation muss der Account Rotator mit `y` oder `n` beantwortet werden. Für unbeaufsichtigte Installationen setze `ZCODE_KIT_ACCOUNT_ROTATOR=y` oder `n`; ohne ausdrückliche Antwort bleibt die bisherige Einstellung erhalten. Eine fehlgeschlagene Verbindungsprüfung bleibt eine Warnung, auch wenn die Installation erfolgreich war. +Der Installer zeigt vier nummerierte Schritte, eine Ergebniszeile pro Assistent (eingerichtet, übersprungen oder fehlgeschlagen), eine Verbindungsprüfung und die Verbindungsdaten für die manuelle Client-Einrichtung. Ausführliche Setup-Ausgaben stehen in der angezeigten `install.log`; mit `ZCODE_KIT_VERBOSE=1` siehst du die vollständige Ausgabe und mit `NO_COLOR=1` einfachen Text. Bei interaktiver Installation wird pro Assistent und getrennt davon für den Account Rotator nach `y` oder `n` gefragt; Strg-C beendet die Fragen, und alles, was du nicht mit `y` beantwortet hast, bleibt unverändert. Für unbeaufsichtigte Installationen wählst du Assistenten ausdrücklich mit `ZCODE_KIT_HARNESSES=omp,codex` (oder `none`) und setzt `ZCODE_KIT_ACCOUNT_ROTATOR=y` oder `n`; ohne ausdrückliche Auswahl werden unentschiedene Assistenten übersprungen, und die bisherige Account-Rotator-Einstellung bleibt erhalten. Antworten werden unter `generated/harness-choices/` gespeichert: Ein späteres Setup, Update oder `zcode-kit doctor --fix` aktualisiert nur Assistenten, denen du zugestimmt hast (ein `y`, eine ausdrückliche Auswahl oder `zcode-kit integrate `), und richtet einen übersprungenen nie erneut ein; eine Integration, die das Kit vor der Frage angelegt hat, wird aktuell gehalten, gilt aber erst nach einem `y` als Zustimmung. `zcode-kit setup --reask` stellt die Frage im Terminal für jeden erkannten Assistenten erneut. Eine fehlgeschlagene Verbindungsprüfung bleibt eine Warnung, auch wenn die Installation erfolgreich war. -> **Bevor du den Installer ausführst:** Er lädt ein Skript herunter und führt es aus, ändert die Konfiguration erkannter Assistenten und kann MCP-Tools registrieren. Das Setup versucht außerdem eine kleine Modellanfrage, die Kontingent verbrauchen kann. Änderungen werden protokolliert; bei einem späteren Fehler können frühere Änderungen dennoch bestehen bleiben. Prüfe den Installer, falls deine Sicherheitsrichtlinien es verlangen. +> **Bevor du den Installer ausführst:** Er lädt ein Skript herunter und führt es aus, ändert die Konfiguration nur der Assistenten, für die du `y` antwortest, und kann für diese MCP-Tools registrieren. Das Setup versucht außerdem eine kleine Modellanfrage, die Kontingent verbrauchen kann. Änderungen werden protokolliert; bei einem späteren Fehler können frühere Änderungen dennoch bestehen bleiben. Prüfe den Installer, falls deine Sicherheitsrichtlinien es verlangen. **Windows — PowerShell, ohne Administratorrechte:** @@ -60,11 +60,29 @@ npm install -g zcode-agent-kit@latest zcode-kit setup --harness auto --installer ``` -npm installiert die Befehle `zcode-kit` und `zcode-agent-kit`. Der zweite Befehl installiert die Kit-Abhängigkeiten, richtet erkannte Assistenten ein und stellt die y/n-Frage zum Account Rotator. Führe ihn nach der npm-Installation aus. Bleibe bei einer Installationsmethode, damit Befehl, Konfiguration und Proxy zur selben Kit-Kopie gehören. +npm installiert die Befehle `zcode-kit` und `zcode-agent-kit`. Der zweite Befehl installiert die Kit-Abhängigkeiten, stellt pro erkanntem Assistenten eine y/n-Frage und danach die y/n-Frage zum Account Rotator. Führe ihn nach der npm-Installation aus. Bleibe bei einer Installationsmethode, damit Befehl, Konfiguration und Proxy zur selben Kit-Kopie gehören. ## Schritt 3 — GLM-5.3(-flash) im gewünschten Assistenten verwenden -Öffne ein neues Terminal und rufe `zcode-kit help` auf. Öffne anschließend ein Terminal **in deinem eigenen Projekt**, nicht im Kit-Verzeichnis. Wähle deinen installierten Assistenten: +Öffne ein neues Terminal und rufe `zcode-kit help` auf. Öffne anschließend ein Terminal **in deinem eigenen Projekt**, nicht im Kit-Verzeichnis. Wähle den Assistenten, den du installiert hast, oder nutze die manuellen Verbindungsdaten mit einem beliebigen anderen Client: + +**Manuell: Basis-URL + API-Schlüssel (jeder OpenAI- oder Anthropic-kompatible Client)** + +Das Kit muss deinen Assistenten nicht konfigurieren. Starte den lokalen Proxy und übernimm die Verbindungsdaten, die er ausgibt; `zcode-kit proxy status` zeigt sie erneut, solange der Proxy läuft: + +```sh +zcode-kit proxy start +``` + +```text +Connection details (local ZCode proxy, running and verified) + OpenAI-compatible base URL: http://127.0.0.1:8457/v1 (POST /chat/completions, POST /responses, GET /models) + Anthropic-compatible base URL: http://127.0.0.1:8457 (POST /v1/messages) + API key (Bearer / x-api-key): + Model IDs: glm-5.3, glm-5.3-flash +``` + +Trage die Basis-URL ein, die zum API-Format deines Clients passt, den Schlüssel als API-Schlüssel oder Auth-Token und eine der Modell-IDs. Der vollständige Schlüssel wird nur in einem interaktiven Terminal ausgegeben; `zcode-kit models --show-key` gibt ihn für Skripte aus, und `zcode-kit proxy status` kennzeichnet die Werte als nicht verifiziert, solange der Proxy nicht läuft. Auch dieser Weg braucht den Proxy des Kits und einen ZCode-Login mit Kontingent; er ersetzt nur die automatische Assistenten-Konfiguration. Der Port stammt aus deiner `proxy/config.yaml`. **OMP:** @@ -86,7 +104,7 @@ zcode-kit run codex -- exec "Reply with ok" -m glm-5.3-flash Diese Befehle starten oder prüfen den Proxy automatisch. Eine Antwort mit `ok` bestätigt den ersten Modellaufruf. Eine Erfolgsmeldung des Setups allein tut das nicht. -**Antwort erhalten?** Dein Konto, der Proxy und der gewählte Assistent haben für diese Anfrage zusammengearbeitet. Du kannst diesen Assistenten jetzt in deinem Projekt verwenden. +**Antwort erhalten?** Dein Konto, der Proxy und der gewählte Client haben für diese Anfrage zusammengearbeitet. Du kannst ihn jetzt in deinem Projekt verwenden. **Keine Antwort?** Nutze die Prüfungen unter „Hilfe“; eine erneute Installation behebt kein aufgebrauchtes Kontingent. @@ -118,7 +136,7 @@ Flash verwendet immer Thinking. Anfragen mit deaktiviertem Thinking werden auf ` | pi | Proxy manuell starten, dann `pi --model zcode/glm-5.3` ausführen. | | Goose | Proxy manuell starten, dann `goose session --provider zcode` ausführen. | | Continue | Continue zuerst öffnen/konfigurieren. `zcode-kit integrate continue` ausführen, Proxy starten und das Modell in der Oberfläche auswählen. | -| Cline / Kilo Code | Generierte Werte in die Erweiterung übernehmen und Proxy starten. Das Setup erzeugt `generated/cline-zcode-values.md` oder `generated/kilo-zcode-values.md` innerhalb einer Release-Installation. | +| Cline / Kilo Code | Nach einem `y` für diesen Assistenten die generierten Werte in die Erweiterung übernehmen und Proxy starten. Das Setup erzeugt dann `generated/cline-zcode-values.md` oder `generated/kilo-zcode-values.md` innerhalb einer Release-Installation. | OMP direkt starten, nicht über `zcode-kit run`. Nur Claude Code, Codex, Aider und OpenCode haben Kit-Launcher. Codex verwendet ein isoliertes Profil; deine üblichen Einstellungen und Skills werden nicht automatisch übernommen. Die Claude-Code-Anbindung ist eine Community-Kompatibilitätslösung. Ein Adapter garantiert nicht, dass jede Client-Version live getestet wurde. @@ -139,7 +157,7 @@ zcode-kit proxy restart zcode-kit proxy stop ``` -`stop`, `restart` und jeder automatische Neustart unterbrechen verbundene Clients und laufende Anfragen; wiederhole diese Anfragen danach. Releases ohne `zcode-kit proxy` rufen `node /proxy/zcode-proxy-manager.mjs` mit demselben Befehl auf. +`stop`, `restart` und jeder automatische Neustart unterbrechen verbundene Clients und laufende Anfragen; wiederhole diese Anfragen danach. Releases ohne `zcode-kit proxy` rufen `node /proxy/zcode-proxy-manager.mjs` mit demselben Befehl auf. `start` und `status` geben die Verbindungsdaten (Basis-URLs, Schlüssel, Modell-IDs) aus, sobald der Proxy als der dieser Installation verifiziert ist; ein gestoppter Proxy wird als nicht laufend gemeldet, und seine konfigurierten Werte werden entsprechend gekennzeichnet. **Hängender Proxy:** `start`, `restart` und `stop` beenden einen nicht antwortenden Proxy nur, wenn er nachweislich zu diesem Kit gehört: Die Startfrist von 60 Sekunden ist abgelaufen, seine Startzeit stimmt mit der aufgezeichneten überein, seine Befehlszeile ist die des Kit-Proxys, und 3 aufeinanderfolgende Gesundheitsprüfungen (etwa 25 Sekunden) schlagen fehl. Ein Prozess mit unbekanntem Besitzer wird nie beendet; der Befehl meldet ihn und bricht ab. `zcode-kit doctor --fix` wendet die verwaltete Konfiguration erneut an und startet den Proxy auf dieselbe Weise, wenn er nicht läuft oder nachweislich hängt. @@ -155,12 +173,14 @@ zcode-kit auth status ``` - **Befehl nicht gefunden:** Terminal neu öffnen. Bei Release-Installationen prüfen, ob `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) oder `$HOME/.local/bin` (macOS/Linux) im PATH steht. -- **Keine Modellantwort:** Desktop-Login und verfügbares Kontingent prüfen. Proxy starten, wenn dein Assistent ihn nicht startet. Eine lokale Gesundheitsprüfung beweist keinen Modellzugriff. +- **Assistent beim Setup übersprungen:** Beim nächsten Mal `y` antworten, `zcode-kit integrate ` ausführen (`zcode-kit setup --harness ` für mehrere) oder mit `zcode-kit setup --reask` alle erkannten Assistenten erneut abfragen lassen. Ohne Terminal Assistenten mit `ZCODE_KIT_HARNESSES` auswählen. +- **Manuelle Client-Einrichtung:** `zcode-kit proxy status` gibt Basis-URLs, Schlüssel und Modell-IDs aus, solange der Proxy läuft; den vollständigen Schlüssel nur in einem interaktiven Terminal (sonst `zcode-kit models --show-key`). +- **Keine Modellantwort:** Desktop-Login und verfügbares Kontingent prüfen. Proxy starten, wenn dein Client ihn nicht startet. Eine lokale Gesundheitsprüfung beweist keinen Modellzugriff. - **Proxy aus oder antwortet nicht:** `zcode-kit doctor --fix` oder `zcode-kit proxy restart` ausführen. Beendet wird nur ein nachweislich eigener, hängender Proxy; siehe den Abschnitt zum manuellen Proxy-Betrieb oben. - **OMP-Autostart:** OMP direkt starten. Das Setup fixiert natives Node/Bun; die Erweiterung führt die Vorprüfung in einem neuen Kindprozess aus, statt Kit-Module in OMP zu importieren. Fehler melden Kategorien ohne Geheimnisse; der Kindprozess ist auf 120 Sekunden begrenzt. Ursache beheben und nach der sitzungsbezogenen Wartezeit von 60 Sekunden erneut versuchen; eine Wiederherstellung ist in derselben Sitzung möglich. Wurde die Laufzeit verschoben, `zcode-kit setup --harness auto` erneut ausführen und die Erweiterung neu laden. Unbekannte Portbesitzer bleiben unberührt. - **Desktop-Login importieren:** Mit `zcode-kit auth login zai --import` den aktuell aktiven `zai`/`start-plan`-Login aus Desktop 0.16.9 importieren; ein ausdrücklich konfigurierter Plan ist erforderlich. Ist `credentials.json` vorhanden, ist diese Datei maßgeblich: Bei ungültigen Anmeldedaten erfolgt kein stiller Rückgriff auf die alte `config.json`. Moderne `coding-plan`-Logins verwenden stattdessen den normalen OAuth-Ablauf mit `zcode-kit auth login zai`; der Importer erstellt oder ermittelt keine API-Schlüssel. - **401 oder Port belegt:** Auf eine weitere Kit-Installation prüfen. Weder Schlüssel löschen noch einen unbekannten Prozess beenden. -- **Setup teilweise fehlgeschlagen:** Den ausgegebenen Rollback-Befehl lesen, bevor du es erneut versuchst. Frühere Änderungen können noch vorhanden sein. +- **Setup teilweise fehlgeschlagen:** Ein fehlgeschlagener Assistent macht die anderen nicht rückgängig; seine eigenen Teiländerungen werden zurückgenommen, und der Installer fährt mit einer Warnung fort. Den ausgegebenen Rollback-Befehl lesen, bevor du es erneut versuchst. Frühere Änderungen können noch vorhanden sein. ## Bevor du echte Projektdaten verwendest diff --git a/README.es.md b/README.es.md index ab01862..2b3823f 100644 --- a/README.es.md +++ b/README.es.md @@ -7,7 +7,7 @@ Usa tu cuenta de ZCode con el asistente de programación que ya utilizas. *El kit se ejecuta localmente; las solicitudes a los modelos se envían a ZCode.* -Conserva tu asistente de programación. Usa tus modelos y tu cuota de ZCode mediante una conexión local. +Conserva tu asistente de programación. Usa tus modelos y tu cuota de ZCode mediante una conexión local. Dos vías equivalentes: deja que el kit configure un asistente detectado después de responder `y` para él, o copia tú mismo la URL base y la clave API mostradas en cualquier cliente compatible con OpenAI o Anthropic. **Rotación de cuentas opcional:** El instalador pregunta **"Do you want to activate the Account Rotator feature? [y/n]"**. Si respondes `y`, se importa la sesión actual y los inicios de sesión posteriores con `zcode-kit auth login zai` se guardan como cuentas adicionales. Volver a iniciar sesión como un usuario ya guardado normalmente actualiza su registro; la documentación explica cómo se identifican los usuarios y las excepciones. Puedes activarla más adelante con `zcode-kit accounts enable` y consultar las cuentas guardadas con `zcode-kit accounts`. `zcode-kit accounts health [--json]` muestra bajo demanda un veredicto por cuenta a partir de los datos de facturación. No demuestra que las solicitudes al modelo funcionen; una cuenta sin datos de cuota no se considera sana y las cuentas no se consultan de forma continua. Consulta la [documentación de Account Rotator (en inglés)](docs/ACCOUNT_ROTATOR.md). @@ -22,11 +22,11 @@ Conserva tu asistente de programación. Usa tus modelos y tu cuota de ZCode medi ## Paso 2 — Instala el kit una vez -Elige el instalador de la versión publicada o npm. El instalador configura automáticamente los asistentes detectados, por lo que no hace falta ejecutar otro comando de configuración después. +Elige el instalador de la versión publicada o npm. El instalador ejecuta la configuración por ti, así que no hace falta ejecutar otro comando de configuración después. La configuración hace una pregunta por asistente detectado, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (con el nombre del asistente), y solo cambia la configuración de ese asistente tras un `y`; `n` lo omite y deja sus ajustes intactos. Sin una respuesta en la terminal, un asistente solo se configura si lo seleccionas explícitamente (ver abajo) o ya diste tu consentimiento antes. -El instalador muestra cuatro etapas numeradas, resultados breves por asistente y una prueba de conexión. El resultado detallado de la configuración se guarda en el `install.log` indicado; usa `ZCODE_KIT_VERBOSE=1` para ver todo o `NO_COLOR=1` para texto sin formato. La instalación interactiva requiere responder `y` o `n` a la pregunta de Account Rotator. Para instalaciones no interactivas, establece `ZCODE_KIT_ACCOUNT_ROTATOR=y` o `n`; sin una respuesta explícita, se conserva la configuración existente. Si falla la prueba de conexión, seguirá siendo una advertencia aunque la instalación haya terminado correctamente. +El instalador muestra cuatro etapas numeradas, una línea de resultado por asistente (configurado, omitido o fallido), una prueba de conexión y los datos de conexión para configurar clientes manualmente. El resultado detallado de la configuración se guarda en el `install.log` indicado; usa `ZCODE_KIT_VERBOSE=1` para ver todo o `NO_COLOR=1` para texto sin formato. La instalación interactiva pregunta `y` o `n` por cada asistente y, por separado, por Account Rotator; Ctrl-C detiene las preguntas y deja intacto todo lo que no hayas respondido con `y`. Para instalaciones desatendidas, selecciona los asistentes explícitamente con `ZCODE_KIT_HARNESSES=omp,codex` (o `none`) y establece `ZCODE_KIT_ACCOUNT_ROTATOR=y` o `n`; sin una selección explícita, los asistentes sin decidir se omiten y se conserva la configuración existente de Account Rotator. Las respuestas se guardan en `generated/harness-choices/`: una configuración posterior, `update` o `zcode-kit doctor --fix` solo actualizan los asistentes que consentiste (un `y`, una selección explícita o `zcode-kit integrate `) y nunca vuelven a integrar uno omitido; una integración que el kit creó antes de preguntar se mantiene al día, pero solo cuenta como consentimiento cuando respondes `y`. `zcode-kit setup --reask` vuelve a preguntar por cada asistente detectado en una terminal. Si falla la prueba de conexión, seguirá siendo una advertencia aunque la instalación haya terminado correctamente. -> **Antes de ejecutar el instalador:** descarga y ejecuta un script, modifica la configuración de los asistentes detectados y puede registrar herramientas MCP. La configuración también intenta hacer una pequeña solicitud al modelo que puede consumir cuota. Los cambios quedan registrados, pero un fallo posterior puede dejar en vigor cambios anteriores. Inspecciona el instalador si lo exige tu política de seguridad. +> **Antes de ejecutar el instalador:** descarga y ejecuta un script, modifica la configuración solo de los asistentes a los que respondes `y` y puede registrar herramientas MCP para ellos. La configuración también intenta hacer una pequeña solicitud al modelo que puede consumir cuota. Los cambios quedan registrados, pero un fallo posterior puede dejar en vigor cambios anteriores. Inspecciona el instalador si lo exige tu política de seguridad. **Windows — PowerShell, sin permisos de administrador:** @@ -60,11 +60,29 @@ npm install -g zcode-agent-kit@latest zcode-kit setup --harness auto --installer ``` -npm instala los comandos `zcode-kit` y `zcode-agent-kit`. El segundo instala las dependencias del kit, configura los asistentes detectados y plantea la pregunta y/n de Account Rotator. Ejecútalo después de instalar el paquete de npm. Usa un solo método de instalación para que el comando, la configuración y el proxy pertenezcan a la misma copia del kit. +npm instala los comandos `zcode-kit` y `zcode-agent-kit`. El segundo instala las dependencias del kit, hace una pregunta y/n por asistente detectado y después la pregunta y/n de Account Rotator. Ejecútalo después de instalar el paquete de npm. Usa un solo método de instalación para que el comando, la configuración y el proxy pertenezcan a la misma copia del kit. ## Paso 3 — Usa GLM-5.3(-flash) en el asistente que prefieras -Abre una terminal nueva y ejecuta `zcode-kit help`. Luego abre una terminal **dentro de tu propio proyecto**, no en la carpeta del kit. Elige el asistente que instalaste: +Abre una terminal nueva y ejecuta `zcode-kit help`. Luego abre una terminal **dentro de tu propio proyecto**, no en la carpeta del kit. Elige el asistente que instalaste o usa los datos de conexión manual con cualquier otro cliente: + +**Manual: URL base + clave API (cualquier cliente compatible con OpenAI o Anthropic)** + +El kit no tiene que configurar tu asistente. Inicia el proxy local y copia los datos de conexión que imprime; `zcode-kit proxy status` los muestra de nuevo mientras el proxy está en ejecución: + +```sh +zcode-kit proxy start +``` + +```text +Connection details (local ZCode proxy, running and verified) + OpenAI-compatible base URL: http://127.0.0.1:8457/v1 (POST /chat/completions, POST /responses, GET /models) + Anthropic-compatible base URL: http://127.0.0.1:8457 (POST /v1/messages) + API key (Bearer / x-api-key): + Model IDs: glm-5.3, glm-5.3-flash +``` + +Introduce la URL base que corresponda al formato de API de tu cliente, la clave como clave API o token de autenticación y uno de los ID de modelo. La clave completa solo se imprime en una terminal interactiva; `zcode-kit models --show-key` la imprime para scripts, y `zcode-kit proxy status` marca los valores como no verificados mientras el proxy no está en ejecución. Esta vía sigue necesitando el proxy del kit y una sesión de ZCode con cuota; solo elimina la configuración automática del asistente. El puerto procede de tu `proxy/config.yaml`. **OMP:** @@ -86,7 +104,7 @@ zcode-kit run codex -- exec "Reply with ok" -m glm-5.3-flash Estos comandos inician o comprueban el proxy automáticamente. Una respuesta `ok` confirma que la primera solicitud al modelo funcionó. Un mensaje de configuración correcta, por sí solo, no lo confirma. -**¿Recibiste la respuesta?** Tu cuenta, el proxy y el asistente elegido funcionaron juntos para esa solicitud. Ya puedes usar ese asistente en tu proyecto. +**¿Recibiste la respuesta?** Tu cuenta, el proxy y el cliente que elegiste funcionaron juntos para esa solicitud. Ya puedes usarlo en tu proyecto. **¿No hubo respuesta?** Usa las comprobaciones de «Ayuda»; reinstalar no solucionará una cuota agotada. @@ -118,7 +136,7 @@ Flash siempre usa thinking. Las solicitudes que desactivan thinking se normaliza | pi | Inicia el proxy manualmente y ejecuta `pi --model zcode/glm-5.3`. | | Goose | Inicia el proxy manualmente y ejecuta `goose session --provider zcode`. | | Continue | Primero abre/configura Continue. Ejecuta `zcode-kit integrate continue`, inicia el proxy y elige el modelo en la interfaz. | -| Cline / Kilo Code | Copia los valores generados a la interfaz de la extensión e inicia el proxy. La configuración crea `generated/cline-zcode-values.md` o `generated/kilo-zcode-values.md` dentro de una instalación de la versión publicada. | +| Cline / Kilo Code | Tras un `y` para ese asistente, copia los valores generados a la interfaz de la extensión e inicia el proxy. La configuración crea entonces `generated/cline-zcode-values.md` o `generated/kilo-zcode-values.md` dentro de una instalación de la versión publicada. | Ejecuta OMP directamente, sin `zcode-kit run`. Solo Claude Code, Codex, Aider y OpenCode tienen lanzadores del kit. Codex usa un perfil aislado: tus ajustes y skills habituales no se transfieren automáticamente. La integración con Claude Code es una solución de compatibilidad de la comunidad. Tener un adaptador no garantiza que cada cliente o versión se haya probado en vivo. @@ -139,7 +157,7 @@ zcode-kit proxy restart zcode-kit proxy stop ``` -`stop`, `restart` y cualquier reinicio automático interrumpen los clientes conectados y las solicitudes en curso; repite esas solicitudes después. Las versiones sin `zcode-kit proxy` ejecutan `node /proxy/zcode-proxy-manager.mjs` con el mismo comando. +`stop`, `restart` y cualquier reinicio automático interrumpen los clientes conectados y las solicitudes en curso; repite esas solicitudes después. Las versiones sin `zcode-kit proxy` ejecutan `node /proxy/zcode-proxy-manager.mjs` con el mismo comando. `start` y `status` imprimen los datos de conexión (URL base, clave, ID de modelo) una vez que el proxy se verifica como el de esta instalación; un proxy detenido se informa como no en ejecución y sus valores configurados se etiquetan como tales. **Proxy bloqueado:** `start`, `restart` y `stop` solo terminan un proxy que no responde cuando está demostrado que pertenece a este kit: ha pasado el periodo de gracia de arranque de 60 segundos, su hora de inicio coincide con la registrada, su línea de comandos es la del proxy del kit y fallan 3 comprobaciones de salud consecutivas (unos 25 segundos). Nunca se termina un proceso cuya propiedad se desconoce; el comando lo indica y se detiene. `zcode-kit doctor --fix` vuelve a aplicar la configuración gestionada y, si el proxy está detenido o bloqueado de forma demostrada, lo inicia del mismo modo. @@ -155,12 +173,14 @@ zcode-kit auth status ``` - **No se encuentra el comando:** vuelve a abrir la terminal. En instalaciones de una versión publicada, comprueba que `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) o `$HOME/.local/bin` (macOS/Linux) esté en PATH. -- **No responde el modelo:** comprueba la sesión de Desktop y la cuota disponible. Inicia el proxy si tu asistente no lo hace. Una comprobación de salud local no demuestra acceso al modelo. +- **Asistente omitido durante la configuración:** responde `y` la próxima vez, ejecuta `zcode-kit integrate ` (`zcode-kit setup --harness ` para varios) o `zcode-kit setup --reask` para que vuelva a preguntar por cada asistente detectado. Sin terminal, selecciona los asistentes con `ZCODE_KIT_HARNESSES`. +- **Configuración manual del cliente:** `zcode-kit proxy status` imprime las URL base, la clave y los ID de modelo mientras el proxy está en ejecución; la clave completa solo en una terminal interactiva (`zcode-kit models --show-key` en otro caso). +- **No responde el modelo:** comprueba la sesión de Desktop y la cuota disponible. Inicia el proxy si tu cliente no lo hace. Una comprobación de salud local no demuestra acceso al modelo. - **Proxy detenido o sin respuesta:** ejecuta `zcode-kit doctor --fix` o `zcode-kit proxy restart`. Solo se termina un proxy bloqueado cuya propiedad por el kit esté demostrada; consulta la sección de gestión manual del proxy más arriba. - **Autoarranque de OMP:** ejecuta OMP directamente. La configuración fija Node/Bun nativos; la extensión realiza la comprobación previa en un proceso hijo nuevo, sin importar módulos del kit en OMP. Los fallos muestran categorías sin secretos; el proceso hijo tiene un límite de 120 segundos. Corrige la causa indicada y reintenta tras los 60 segundos de espera por sesión; es posible recuperarse en la misma sesión. Si cambió la ubicación del runtime, ejecuta de nuevo `zcode-kit setup --harness auto` y recarga la extensión. No se modifican procesos desconocidos que ocupen el puerto. - **Importar el login de Desktop:** ejecuta `zcode-kit auth login zai --import` para importar el login activo de `zai`/`start-plan` en Desktop 0.16.9; se requiere un plan configurado explícitamente. Si existe `credentials.json`, es la fuente autoritativa: unas credenciales inválidas no provocan una vuelta silenciosa al antiguo `config.json`. Los logins modernos de `coding-plan` usan el OAuth normal con `zcode-kit auth login zai`; el importador no crea ni obtiene claves API. - **401 o puerto ocupado:** comprueba si existe otra instalación del kit. No borres claves ni termines un proceso que no reconoces. -- **La configuración falló a medias:** lee el comando de rollback mostrado antes de volver a intentarlo. Es posible que queden cambios anteriores. +- **La configuración falló a medias:** que falle un asistente no deshace los demás; sus propios cambios parciales se revierten y el instalador continúa con una advertencia. Lee el comando de rollback mostrado antes de volver a intentarlo. Es posible que queden cambios anteriores. ## Antes de usar datos reales del proyecto diff --git a/README.ja.md b/README.ja.md index b85c4f7..6c1d8f3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -7,7 +7,7 @@ *kit はローカルで動作し、モデルへのリクエストは ZCode に送られます。* -コーディングアシスタントはそのままに、ローカル接続を通じて既存の ZCode モデルと利用枠を使えます。 +コーディングアシスタントはそのままに、ローカル接続を通じて既存の ZCode モデルと利用枠を使えます。入口は 2 つあり、どちらも同等です。検出したアシスタントに `y` と答えて kit に設定させるか、表示されたベース URL と API キーを OpenAI/Anthropic 互換の任意のクライアントに自分で設定します。 **オプションのアカウントローテーション:** インストーラーは **"Do you want to activate the Account Rotator feature? [y/n]"** と尋ねます。`y` を選ぶと現在のログインが取り込まれ、その後の `zcode-kit auth login zai` による別アカウントのログインは追加のアカウントとして保存されます。保存済みのユーザーとして再ログインすると、通常はその登録情報が更新されます。ユーザーの照合方法と例外はドキュメントで説明しています。後から有効にするには `zcode-kit accounts enable`、保存済みアカウントの確認には `zcode-kit accounts` を使います。`zcode-kit accounts health [--json]` は、課金データに基づくアカウントごとの判定を要求時に表示します。モデルへのリクエストが成功することの証明ではありません。利用枠データのないアカウントは正常とは見なされず、アカウントを継続的にポーリングすることもありません。詳しくは [Account Rotator のドキュメント (英語)](docs/ACCOUNT_ROTATOR.md) を参照してください。 @@ -22,11 +22,11 @@ ## ステップ 2 — kit を一度インストール -以下のリリース版インストーラーまたは npm を選びます。リリース版インストーラーは検出したアシスタントを自動設定するため、後から別のセットアップコマンドを実行する必要はありません。 +以下のリリース版インストーラーまたは npm を選びます。リリース版インストーラーはセットアップも実行するため、後から別のセットアップコマンドを実行する必要はありません。セットアップは検出したアシスタントごとに **"Configure ZCode as a provider with its supported models in ? [y/n]"**(アシスタント名入り)と 1 回ずつ質問し、`y` と答えた場合だけそのアシスタントの設定を変更します。`n` はスキップし、設定には触れません。ターミナルでの回答がない場合、アシスタントが設定されるのは明示的に選択したとき(後述)か、以前に同意したときだけです。 -インストーラーは番号付きの 4 段階、アシスタントごとの簡潔な結果、接続確認を表示します。詳しいセットアップ出力は表示された `install.log` に保存されます。`ZCODE_KIT_VERBOSE=1` で全出力、`NO_COLOR=1` でプレーンテキストにできます。対話的なインストールでは Account Rotator の質問に `y` または `n` で答える必要があります。無人インストールでは `ZCODE_KIT_ACCOUNT_ROTATOR=y` または `n` を指定してください。明示的な回答がなければ、既存の設定が維持されます。接続確認に失敗しても、インストール自体が成功した場合は警告として扱われます。 +インストーラーは番号付きの 4 段階、アシスタントごとの結果行(設定済み、スキップ、失敗)、接続確認、および手動でクライアントを設定するための接続情報を表示します。詳しいセットアップ出力は表示された `install.log` に保存されます。`ZCODE_KIT_VERBOSE=1` で全出力、`NO_COLOR=1` でプレーンテキストにできます。対話的なインストールでは、アシスタントごとに、さらに別途 Account Rotator について `y` または `n` を尋ねます。Ctrl-C で質問を中止でき、`y` と答えていないものはすべてそのままです。無人インストールでは `ZCODE_KIT_HARNESSES=omp,codex`(または `none`)でアシスタントを明示的に選択し、`ZCODE_KIT_ACCOUNT_ROTATOR=y` または `n` を指定してください。明示的な選択がなければ、未決定のアシスタントはスキップされ、既存の Account Rotator 設定が維持されます。回答は `generated/harness-choices/` に保存されます。その後のセットアップ、update、`zcode-kit doctor --fix` は同意したアシスタント(`y`、明示的な選択、または `zcode-kit integrate <アシスタント>`)だけを更新し、スキップしたアシスタントを再統合することはありません。質問より前に kit が作成した統合は最新に保たれますが、`y` と答えるまで同意とは見なされません。`zcode-kit setup --reask` はターミナルで、検出したすべてのアシスタントについて改めて質問します。接続確認に失敗しても、インストール自体が成功した場合は警告として扱われます。 -> **インストーラーを実行する前に:** スクリプトをダウンロードして実行し、検出したアシスタントの設定を変更します。MCP ツールを登録する場合もあります。セットアップでは、利用枠を消費する小さなモデルリクエストも試みます。変更は記録されますが、途中で失敗するとそれ以前の変更が残ることがあります。セキュリティポリシーで必要な場合は、実行前にスクリプトを確認してください。 +> **インストーラーを実行する前に:** スクリプトをダウンロードして実行し、`y` と答えたアシスタントだけ設定を変更し、そのアシスタント向けに MCP ツールを登録する場合があります。セットアップでは、利用枠を消費する小さなモデルリクエストも試みます。変更は記録されますが、途中で失敗するとそれ以前の変更が残ることがあります。セキュリティポリシーで必要な場合は、実行前にスクリプトを確認してください。 **Windows — PowerShell、管理者権限は不要:** @@ -60,11 +60,29 @@ npm install -g zcode-agent-kit@latest zcode-kit setup --harness auto --installer ``` -npm は `zcode-kit` と `zcode-agent-kit` のコマンドをインストールします。2 行目のコマンドは kit の依存関係をインストールし、検出したアシスタントを設定して、Account Rotator の y/n 質問を表示します。npm パッケージをインストールした後に実行してください。コマンド、設定、プロキシが同じ kit に属するよう、インストール方法を統一してください。 +npm は `zcode-kit` と `zcode-agent-kit` のコマンドをインストールします。2 行目のコマンドは kit の依存関係をインストールし、検出したアシスタントごとに y/n の質問をしてから、Account Rotator の y/n 質問を表示します。npm パッケージをインストールした後に実行してください。コマンド、設定、プロキシが同じ kit に属するよう、インストール方法を統一してください。 ## ステップ 3 — 好きなアシスタントで GLM-5.3(-flash) を使う -新しいターミナルを開き、`zcode-kit help` を実行します。その後、kit フォルダーではなく **自分のプロジェクト内** でターミナルを開きます。インストールしたアシスタントを選んでください。 +新しいターミナルを開き、`zcode-kit help` を実行します。その後、kit フォルダーではなく **自分のプロジェクト内** でターミナルを開きます。インストールしたアシスタントを選ぶか、手動の接続情報を使って他の任意のクライアントを設定してください。 + +**手動: ベース URL + API キー(OpenAI/Anthropic 互換の任意のクライアント)** + +kit にアシスタントを設定させる必要はありません。ローカルプロキシを起動し、表示される接続情報をコピーしてください。プロキシの実行中は `zcode-kit proxy status` でも再表示できます。 + +```sh +zcode-kit proxy start +``` + +```text +Connection details (local ZCode proxy, running and verified) + OpenAI-compatible base URL: http://127.0.0.1:8457/v1 (POST /chat/completions, POST /responses, GET /models) + Anthropic-compatible base URL: http://127.0.0.1:8457 (POST /v1/messages) + API key (Bearer / x-api-key): <ローカルプロキシのキー> + Model IDs: glm-5.3, glm-5.3-flash +``` + +クライアントの API 形式に合うベース URL、API キーまたは認証トークンとしてのキー、モデル ID のいずれかを入力します。完全なキーは対話的なターミナルでのみ表示されます。スクリプト向けには `zcode-kit models --show-key` が出力し、プロキシが動いていない間は `zcode-kit proxy status` が値を未検証として表示します。この方法でも kit のプロキシと利用枠のある ZCode ログインは必要で、省略できるのはアシスタントの自動設定だけです。ポートは `proxy/config.yaml` から取得されます。 **OMP:** @@ -86,7 +104,7 @@ zcode-kit run codex -- exec "Reply with ok" -m glm-5.3-flash これらのコマンドは必要に応じてプロキシを起動・確認します。`ok` という返答があれば、最初のモデル呼び出しは成功です。セットアップの成功メッセージだけでは確認できません。 -**返答がありましたか?** そのリクエストについて、アカウント、プロキシ、選んだアシスタントが連携して動きました。自分のプロジェクトで使い始められます。 +**返答がありましたか?** そのリクエストについて、アカウント、プロキシ、選んだクライアントが連携して動きました。自分のプロジェクトで使い始められます。 **返答がありませんか?** 下の「ヘルプ」の確認項目を使ってください。利用枠が尽きている場合、再インストールしても解決しません。 @@ -118,7 +136,7 @@ Flash は常に thinking を使用します。thinking を無効にしたリク | pi | プロキシを手動で起動してから `pi --model zcode/glm-5.3` を実行します。 | | Goose | プロキシを手動で起動してから `goose session --provider zcode` を実行します。 | | Continue | 先に Continue を開いて設定します。`zcode-kit integrate continue` を実行し、プロキシを起動して UI でモデルを選びます。 | -| Cline / Kilo Code | 生成された値を拡張機能の UI に入力してプロキシを起動します。リリース版では `generated/cline-zcode-values.md` または `generated/kilo-zcode-values.md` が生成されます。 | +| Cline / Kilo Code | そのアシスタントに `y` と答えた後、生成された値を拡張機能の UI に入力してプロキシを起動します。リリース版では、その場合に `generated/cline-zcode-values.md` または `generated/kilo-zcode-values.md` が生成されます。 | OMP は直接実行します。`zcode-kit run` は使用しません。kit のランチャーがあるのは Claude Code、Codex、Aider、OpenCode のみです。Codex は分離したプロファイルを使うため、通常の設定やスキルは自動では引き継がれません。Claude Code のルーティングはコミュニティによる互換機能です。アダプターがあっても、すべてのクライアントやバージョンを実環境で検証したことにはなりません。 @@ -139,7 +157,7 @@ zcode-kit proxy restart zcode-kit proxy stop ``` -`stop`、`restart`、および自動再起動は、接続中のクライアントと処理中のリクエストを中断します。その後リクエストを再試行してください。`zcode-kit proxy` のないリリースでは、同じコマンドを付けて `node <インストール先>/proxy/zcode-proxy-manager.mjs` を実行します。 +`stop`、`restart`、および自動再起動は、接続中のクライアントと処理中のリクエストを中断します。その後リクエストを再試行してください。`zcode-kit proxy` のないリリースでは、同じコマンドを付けて `node <インストール先>/proxy/zcode-proxy-manager.mjs` を実行します。`start` と `status` は、プロキシがこのインストールのものと検証できた時点で接続情報(ベース URL、キー、モデル ID)を表示します。停止中のプロキシは未起動として報告され、設定値にはその旨のラベルが付きます。 **応答しないプロキシ:** `start`、`restart`、`stop` が応答しないプロキシを終了するのは、それがこの kit 自身のものと証明できた場合だけです。条件は、60 秒の起動猶予期間を過ぎていること、起動時刻が記録と一致すること、コマンドラインが kit のプロキシであること、ヘルスチェックが 3 回連続(約 25 秒)で失敗することです。所有者が不明なプロセスは決して終了せず、コマンドはその旨を報告して中止します。`zcode-kit doctor --fix` は管理対象の設定を再適用し、プロキシが停止しているか応答しないことが証明された場合は同じ方法で起動します。 @@ -155,12 +173,14 @@ zcode-kit auth status ``` - **コマンドが見つからない:** ターミナルを開き直してください。リリース版のインストールでは、`%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) または `$HOME/.local/bin` (macOS/Linux) が PATH にあるか確認します。 -- **モデルから返答がない:** Desktop のログインと残りの利用枠を確認します。アシスタントがプロキシを起動しない場合は、手動で起動します。ローカルのヘルスチェックだけではモデルへのアクセスは確認できません。 +- **セットアップでアシスタントをスキップした:** 次回 `y` と答えるか、`zcode-kit integrate <アシスタント>`(複数なら `zcode-kit setup --harness <リスト>`)を実行するか、`zcode-kit setup --reask` で検出したすべてのアシスタントについて改めて質問させます。ターミナルがない場合は `ZCODE_KIT_HARNESSES` でアシスタントを選択します。 +- **クライアントの手動設定:** `zcode-kit proxy status` はプロキシの実行中にベース URL、キー、モデル ID を表示します。完全なキーは対話的なターミナルでのみ表示されます(それ以外は `zcode-kit models --show-key`)。 +- **モデルから返答がない:** Desktop のログインと残りの利用枠を確認します。クライアントがプロキシを起動しない場合は、手動で起動します。ローカルのヘルスチェックだけではモデルへのアクセスは確認できません。 - **プロキシが停止している、または応答しない:** `zcode-kit doctor --fix` または `zcode-kit proxy restart` を実行します。終了されるのは、kit 自身のものと証明された応答のないプロキシだけです。上の手動操作のセクションを参照してください。 - **OMP の自動起動:** OMP を直接起動します。セットアップでネイティブの Node/Bun を固定し、拡張機能は kit モジュールを OMP にインポートせず、新しい子プロセスで事前確認を行います。失敗時は機密情報を含まないカテゴリを表示し、子プロセスの実行時間は最大 120 秒です。原因を修正し、セッションごとの 60 秒の待機時間後に再試行してください。同じセッション内で復旧できます。ランタイムを移動した場合は `zcode-kit setup --harness auto` を再実行し、拡張機能を再読み込みします。ポートを使用している不明なプロセスには干渉しません。 - **Desktop のログインをインポート:** `zcode-kit auth login zai --import` で Desktop 0.16.9 の現在アクティブな `zai`/`start-plan` ログインを取り込みます。プランの明示的な設定が必要です。`credentials.json` が存在する場合はそれが正となり、認証情報が無効でも旧 `config.json` へ暗黙にはフォールバックしません。新形式の `coding-plan` ログインでは、代わりに `zcode-kit auth login zai` で通常の OAuth を使います。インポーターは API キーの作成や取得を行いません。 - **401 またはポートが使用中:** 別の kit がインストールされていないか確認します。キーを削除したり、不明なプロセスを終了したりしないでください。 -- **セットアップが途中で失敗した:** 再実行する前に表示されたロールバックコマンドを確認します。以前の変更が残っている場合があります。 +- **セットアップが途中で失敗した:** 1 つのアシスタントの失敗が他を取り消すことはありません。そのアシスタント自身の部分的な変更は元に戻され、インストーラーは警告を出して続行します。再実行する前に表示されたロールバックコマンドを確認します。以前の変更が残っている場合があります。 ## 実際のプロジェクトデータを使う前に diff --git a/README.md b/README.md index 1712ea3..e24c22d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Use your ZCode account with the coding assistant you already use. *The kit runs locally; model requests go to ZCode.* -Keep your coding assistant. Use your existing ZCode models and quota/tokens through a local connection. +Keep your coding assistant. Use your existing ZCode models and quota/tokens through a local connection. Two equal ways in: let the kit configure a detected assistant after you answer `y` for it, or take the printed base URL and API key into any OpenAI- or Anthropic-compatible client yourself. **Optional account rotation:** The installer asks **"Do you want to activate the Account Rotator feature? [y/n]"**. With `y`, the current login is imported and later `zcode-kit auth login zai` logins are saved as additional accounts. Signing in again as an already saved user normally updates that entry; the documentation explains how users are matched and the exceptions. Enable later with `zcode-kit accounts enable`; inspect saved accounts with `zcode-kit accounts`. `zcode-kit accounts health [--json]` shows one on-demand verdict per account from billing data. It is not proof that model requests work; an account without quota data is not counted as healthy, and accounts are not polled continuously. See the [account rotator documentation](docs/ACCOUNT_ROTATOR.md). @@ -22,11 +22,11 @@ Keep your coding assistant. Use your existing ZCode models and quota/tokens thro ## Step 2 — Install the kit once -Choose the release installer or npm below. The release installer sets up detected assistants automatically, so there is no separate setup command to run afterward. +Choose the release installer or npm below. The release installer runs setup for you, so there is no separate setup command to run afterward. Setup asks one question per detected assistant, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (with the assistant's name), and changes that assistant's configuration only after a `y`; `n` skips it and leaves its settings untouched. Without a terminal answer an assistant is configured only when you select it explicitly (see below) or consented earlier. -The installer shows four numbered stages, compact assistant results and a connection check. Detailed setup output is saved to the displayed `install.log`; set `ZCODE_KIT_VERBOSE=1` for full output and `NO_COLOR=1` for plain text. Interactive installs require `y` or `n` for Account Rotator. For unattended installs, set `ZCODE_KIT_ACCOUNT_ROTATOR=y` or `n`; without an explicit answer the existing setting is preserved. A failed connection check remains a warning even when installation succeeds. +The installer shows four numbered stages, one result line per assistant (configured, skipped or failed), a connection check and the connection details for manual client setup. Detailed setup output is saved to the displayed `install.log`; set `ZCODE_KIT_VERBOSE=1` for full output and `NO_COLOR=1` for plain text. Interactive installs ask `y` or `n` per assistant and, separately, for Account Rotator; Ctrl-C stops the questions and leaves everything you have not answered `y` to untouched. For unattended installs, select assistants explicitly with `ZCODE_KIT_HARNESSES=omp,codex` (or `none`) and set `ZCODE_KIT_ACCOUNT_ROTATOR=y` or `n`; without an explicit selection, undecided assistants are skipped and the existing Account Rotator setting is preserved. Answers are stored under `generated/harness-choices/`: a later setup, update or `zcode-kit doctor --fix` refreshes only assistants you consented to (a `y`, an explicit selection or `zcode-kit integrate `) and never re-integrates a skipped one; an integration the kit created before it asked is kept current but only counts as consent once you answer `y`. `zcode-kit setup --reask` asks every detected assistant again on a terminal. A failed connection check remains a warning even when installation succeeds. -> **Before you run the installer:** it downloads and executes a script, changes configuration for detected assistants, and may register MCP tools. Setup also attempts a small model request that can use quota. Changes are recorded, but a later failure can leave earlier changes in place. Inspect the installer if required by your security policy. +> **Before you run the installer:** it downloads and executes a script, changes configuration only for assistants you answer `y` to, and may register MCP tools for those. Setup also attempts a small model request that can use quota. Changes are recorded, but a later failure can leave earlier changes in place. Inspect the installer if required by your security policy. **Windows — PowerShell, without administrator rights:** @@ -60,11 +60,29 @@ npm install -g zcode-agent-kit@latest zcode-kit setup --harness auto --installer ``` -npm installs the `zcode-kit` and `zcode-agent-kit` commands. The second command installs the kit's dependencies, configures detected assistants and asks the Account Rotator y/n question. Run it after installing the npm package. Use one installation method to keep your command, configuration and proxy tied to the same kit copy. +npm installs the `zcode-kit` and `zcode-agent-kit` commands. The second command installs the kit's dependencies, asks one y/n question per detected assistant and then the Account Rotator y/n question. Run it after installing the npm package. Use one installation method to keep your command, configuration and proxy tied to the same kit copy. ## Step 3 — Use GLM-5.3(-flash) in your harness of choice -Open a new terminal and run `zcode-kit help`. Then open a terminal **inside your own project**, not the kit folder. Choose the assistant you installed: +Open a new terminal and run `zcode-kit help`. Then open a terminal **inside your own project**, not the kit folder. Choose the assistant you installed, or use the manual connection details with any other client: + +**Manual: base URL + API key (any OpenAI- or Anthropic-compatible client)** + +The kit does not have to configure your assistant. Start the local proxy and copy the connection details it prints; `zcode-kit proxy status` shows them again while the proxy runs: + +```sh +zcode-kit proxy start +``` + +```text +Connection details (local ZCode proxy, running and verified) + OpenAI-compatible base URL: http://127.0.0.1:8457/v1 (POST /chat/completions, POST /responses, GET /models) + Anthropic-compatible base URL: http://127.0.0.1:8457 (POST /v1/messages) + API key (Bearer / x-api-key): + Model IDs: glm-5.3, glm-5.3-flash +``` + +Enter the base URL that matches your client's API format, the key as the API key or auth token, and one of the model IDs. The full key is printed only in an interactive terminal; `zcode-kit models --show-key` prints it for scripts, and `zcode-kit proxy status` labels the values as unverified while the proxy is not running. This path still needs the kit's proxy and a ZCode login with quota; it only removes the automatic assistant configuration. The port comes from your `proxy/config.yaml`. **OMP:** @@ -86,7 +104,7 @@ zcode-kit run codex -- exec "Reply with ok" -m glm-5.3-flash These commands start/check the proxy automatically. A reply of `ok` confirms the first model call. A successful setup message alone does not. -**Got the reply?** Your account, proxy, and selected assistant worked together for that request. You can now use that assistant in your project. +**Got the reply?** Your account, proxy, and the client you chose worked together for that request. You can now use it in your project. **No reply?** Use the checks under “Get help” below; reinstalling will not fix an exhausted quota. @@ -118,7 +136,7 @@ Flash always uses thinking. Requests that disable thinking are normalized to `lo | pi | Start the proxy manually, then run `pi --model zcode/glm-5.3`. | | Goose | Start the proxy manually, then run `goose session --provider zcode`. | | Continue | Open/configure Continue first. Run `zcode-kit integrate continue`, start the proxy, then select the model in the UI. | -| Cline / Kilo Code | Copy the generated values into the extension UI and start the proxy. Setup creates `generated/cline-zcode-values.md` or `generated/kilo-zcode-values.md` inside a release installation. | +| Cline / Kilo Code | After a `y` for that assistant, copy the generated values into the extension UI and start the proxy. Setup then creates `generated/cline-zcode-values.md` or `generated/kilo-zcode-values.md` inside a release installation. | Run OMP directly, not through `zcode-kit run`. Only Claude Code, Codex, Aider, and OpenCode have kit launchers. Codex uses an isolated profile; your usual personal settings and skills do not automatically carry over. Claude Code routing is community compatibility. An adapter is not a guarantee that every client/version has been live-tested. @@ -139,7 +157,7 @@ zcode-kit proxy restart zcode-kit proxy stop ``` -`stop`, `restart` and every automatic restart interrupt connected clients and in-flight requests; retry those requests afterward. Releases without `zcode-kit proxy` run `node /proxy/zcode-proxy-manager.mjs` with the same command. +`stop`, `restart` and every automatic restart interrupt connected clients and in-flight requests; retry those requests afterward. Releases without `zcode-kit proxy` run `node /proxy/zcode-proxy-manager.mjs` with the same command. `start` and `status` print the connection details (base URLs, key, model IDs) once the proxy is verified as this installation's own; a stopped proxy is reported as not running and its configured values are labelled as such. **Hung proxy:** `start`, `restart` and `stop` terminate a proxy that does not answer only when it is proven to be this kit's own: it is past the 60-second startup grace, its start time matches the recorded one, its command line is the kit proxy, and 3 consecutive health checks (about 25 seconds) fail. A process whose ownership is unknown is never killed; the command reports it and stops. `zcode-kit doctor --fix` reapplies managed configuration and, if the proxy is down or proven hung, starts it the same way. @@ -155,12 +173,14 @@ zcode-kit auth status ``` - **Command not found:** reopen the terminal. For release installs, check that `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) or `$HOME/.local/bin` (macOS/Linux) is on PATH. -- **No model reply:** check the Desktop login and available quota. Start the proxy if your assistant does not start it. Local health does not prove model access. +- **Assistant skipped during setup:** answer `y` next time, run `zcode-kit integrate ` (`zcode-kit setup --harness ` for several), or `zcode-kit setup --reask` to be asked again for every detected assistant. Without a terminal, select assistants with `ZCODE_KIT_HARNESSES`. +- **Manual client setup:** `zcode-kit proxy status` prints the base URLs, key and model IDs while the proxy runs; the full key only in an interactive terminal (`zcode-kit models --show-key` otherwise). +- **No model reply:** check the Desktop login and available quota. Start the proxy if your client does not start it. Local health does not prove model access. - **Proxy down or not answering:** run `zcode-kit doctor --fix` or `zcode-kit proxy restart`. Only a proven-own hung proxy is terminated; see the manual proxy section above. - **OMP autostart:** launch OMP directly. Setup pins native Node/Bun; the extension runs preflight in a fresh child instead of importing kit modules into OMP. Failures report secret-free categories; the child is limited to 120 seconds. Fix the reported cause, then retry after the 60-second per-session cooldown; recovery is possible in the same session. If the runtime moved, rerun `zcode-kit setup --harness auto` and reload the extension. Unknown port owners are left untouched. - **Desktop login import:** run `zcode-kit auth login zai --import` to import the current active Desktop 0.16.9 `zai`/`start-plan` login; an explicitly configured plan is required. If `credentials.json` exists, it is authoritative: invalid credentials do not silently fall back to legacy `config.json`. Modern `coding-plan` logins use normal OAuth with `zcode-kit auth login zai` instead; the importer does not create or resolve API keys. - **401 or occupied port:** check for another kit installation. Do not delete keys or kill an unknown process. -- **Setup partly failed:** read the printed rollback command before trying again. Earlier changes may still exist. +- **Setup partly failed:** one assistant failing does not undo the others; its own partial changes are reverted and the installer continues with a warning. Read the printed rollback command before trying again. Earlier changes may still exist. ## Before you use real project data diff --git a/README.zh-CN.md b/README.zh-CN.md index 53fcede..043d9cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,7 @@ *Kit 在本机运行;模型请求会发送到 ZCode。* -继续使用熟悉的编程助手,通过本地连接使用现有的 ZCode 模型和额度。 +继续使用熟悉的编程助手,通过本地连接使用现有的 ZCode 模型和额度。两种入口同样有效:为检测到的助手回答 `y`,让 Kit 为你配置;或者把输出的基础 URL 和 API 密钥自行填入任意 OpenAI 或 Anthropic 兼容客户端。 **可选的账号轮换:**安装器会询问 **"Do you want to activate the Account Rotator feature? [y/n]"**。回答 `y` 后,当前登录会被导入;以后通过 `zcode-kit auth login zai` 登录的新账号会作为额外账号保存。以已保存的用户身份再次登录,通常会更新该记录;用户如何匹配及例外情况见文档。之后可以用 `zcode-kit accounts enable` 启用,用 `zcode-kit accounts` 查看保存的账号。`zcode-kit accounts health [--json]` 按需根据计费数据为每个账号显示一个判定。它不能证明模型请求可用;没有额度数据的账号不会被视为健康,也不会持续轮询账号。详见[账号轮换文档(英文)](docs/ACCOUNT_ROTATOR.md)。 @@ -22,11 +22,11 @@ ## 第 2 步 — 安装一次 Kit -在下面的正式版安装器和 npm 之间选择一种方式。正式版安装器会自动配置检测到的助手,无须再单独运行设置命令。 +在下面的正式版安装器和 npm 之间选择一种方式。正式版安装器会替你运行设置,无须再单独运行设置命令。设置会对每个检测到的助手提问一次:**"Configure ZCode as a provider with its supported models in ? [y/n]"**(其中是助手名称),只有回答 `y` 后才修改该助手的配置;回答 `n` 会跳过它,其设置保持不变。没有终端回答时,只有在你明确选择(见下文)或此前已同意的情况下才会配置助手。 -安装器会显示四个编号步骤、简洁的助手配置结果和连接检查。详细的设置输出保存在屏幕提示的 `install.log` 中;设置 `ZCODE_KIT_VERBOSE=1` 可显示全部输出,设置 `NO_COLOR=1` 可显示纯文本。交互式安装必须对账号轮换问题回答 `y` 或 `n`。无人值守安装可设置 `ZCODE_KIT_ACCOUNT_ROTATOR=y` 或 `n`;如果没有明确回答,会保留原有设置。即使安装成功,连接检查失败仍会显示为警告。 +安装器会显示四个编号步骤、每个助手一行结果(已配置、已跳过或失败)、连接检查,以及供手动配置客户端使用的连接详情。详细的设置输出保存在屏幕提示的 `install.log` 中;设置 `ZCODE_KIT_VERBOSE=1` 可显示全部输出,设置 `NO_COLOR=1` 可显示纯文本。交互式安装会对每个助手以及(单独地)账号轮换问题询问 `y` 或 `n`;按 Ctrl-C 会停止提问,所有未回答 `y` 的项目保持不变。无人值守安装请用 `ZCODE_KIT_HARNESSES=omp,codex`(或 `none`)明确选择助手,并设置 `ZCODE_KIT_ACCOUNT_ROTATOR=y` 或 `n`;没有明确选择时,未决定的助手会被跳过,原有的账号轮换设置保持不变。回答保存在 `generated/harness-choices/` 中:之后的设置、update 或 `zcode-kit doctor --fix` 只会刷新你已同意的助手(回答 `y`、明确选择或 `zcode-kit integrate <助手>`),绝不会重新集成已跳过的助手;Kit 在提问之前创建的集成会保持更新,但只有在你回答 `y` 后才算作同意。`zcode-kit setup --reask` 会在终端中对每个检测到的助手重新提问。即使安装成功,连接检查失败仍会显示为警告。 -> **运行安装器之前:**安装器会下载并执行脚本、修改已检测助手的配置,还可能注册 MCP 工具。设置时也会尝试一次小型模型请求,可能消耗额度。变更会被记录,但之后若发生错误,先前的变更仍可能保留。如果安全政策有要求,请先检查安装脚本。 +> **运行安装器之前:**安装器会下载并执行脚本,只修改你回答 `y` 的助手的配置,并可能为这些助手注册 MCP 工具。设置时也会尝试一次小型模型请求,可能消耗额度。变更会被记录,但之后若发生错误,先前的变更仍可能保留。如果安全政策有要求,请先检查安装脚本。 **Windows — PowerShell,无须管理员权限:** @@ -60,11 +60,29 @@ npm install -g zcode-agent-kit@latest zcode-kit setup --harness auto --installer ``` -npm 会安装 `zcode-kit` 和 `zcode-agent-kit` 两个命令。第二个命令会安装 Kit 的依赖项、配置检测到的助手,并询问账号轮换的 y/n 问题。安装 npm 包后请运行该命令。使用同一种安装方式,以确保命令、配置和代理属于同一份 Kit。 +npm 会安装 `zcode-kit` 和 `zcode-agent-kit` 两个命令。第二个命令会安装 Kit 的依赖项,对每个检测到的助手提出一个 y/n 问题,然后询问账号轮换的 y/n 问题。安装 npm 包后请运行该命令。使用同一种安装方式,以确保命令、配置和代理属于同一份 Kit。 ## 第 3 步 — 在所选助手中使用 GLM-5.3(-flash) -打开新终端并运行 `zcode-kit help`。然后在**自己的项目目录内**打开终端,不要在 Kit 目录内运行。选择已安装的助手: +打开新终端并运行 `zcode-kit help`。然后在**自己的项目目录内**打开终端,不要在 Kit 目录内运行。选择已安装的助手,或使用手动连接详情接入任意其他客户端: + +**手动:基础 URL + API 密钥(任意 OpenAI 或 Anthropic 兼容客户端)** + +Kit 无须配置你的助手。启动本地代理并复制它输出的连接详情;代理运行期间,`zcode-kit proxy status` 会再次显示这些信息: + +```sh +zcode-kit proxy start +``` + +```text +Connection details (local ZCode proxy, running and verified) + OpenAI-compatible base URL: http://127.0.0.1:8457/v1 (POST /chat/completions, POST /responses, GET /models) + Anthropic-compatible base URL: http://127.0.0.1:8457 (POST /v1/messages) + API key (Bearer / x-api-key): <你的本地代理密钥> + Model IDs: glm-5.3, glm-5.3-flash +``` + +填入与客户端 API 格式匹配的基础 URL,把密钥作为 API 密钥或认证令牌填入,并选择一个模型 ID。完整密钥只会在交互式终端中输出;`zcode-kit models --show-key` 可为脚本输出密钥,代理未运行时 `zcode-kit proxy status` 会把这些值标记为未验证。此方式仍需要 Kit 的代理以及有额度的 ZCode 登录;它只省去自动配置助手这一步。端口来自你的 `proxy/config.yaml`。 **OMP:** @@ -86,7 +104,7 @@ zcode-kit run codex -- exec "Reply with ok" -m glm-5.3-flash 这些命令会自动启动或检查代理。收到 `ok` 回复才表示第一次模型调用成功;仅有设置成功的消息不足以证明模型可用。 -**收到回复了吗?**对于这次请求,你的账号、代理和所选助手已协同工作。现在可以在自己的项目中使用该助手。 +**收到回复了吗?**对于这次请求,你的账号、代理和所选客户端已协同工作。现在可以在自己的项目中使用它。 **没有回复?**请使用下方“获取帮助”中的检查项;额度耗尽无法靠重新安装解决。 @@ -118,7 +136,7 @@ Flash 始终启用 thinking。禁用 thinking 的请求会被规范化为 `low` | pi | 手动启动代理,然后运行 `pi --model zcode/glm-5.3`。 | | Goose | 手动启动代理,然后运行 `goose session --provider zcode`。 | | Continue | 先打开并配置 Continue。运行 `zcode-kit integrate continue`,启动代理,再在界面中选模型。 | -| Cline / Kilo Code | 将生成的配置值填入扩展的界面并启动代理。正式版安装会在安装目录中生成 `generated/cline-zcode-values.md` 或 `generated/kilo-zcode-values.md`。 | +| Cline / Kilo Code | 为该助手回答 `y` 后,将生成的配置值填入扩展的界面并启动代理。正式版安装随后会在安装目录中生成 `generated/cline-zcode-values.md` 或 `generated/kilo-zcode-values.md`。 | 直接运行 OMP,不要通过 `zcode-kit run` 启动。只有 Claude Code、Codex、Aider 和 OpenCode 提供 Kit 启动器。Codex 使用隔离的配置环境;你平时使用的设置和技能不会自动继承。Claude Code 的路由属于社区兼容方案。存在适配器并不保证每个客户端或版本都经过实际测试。 @@ -139,7 +157,7 @@ zcode-kit proxy restart zcode-kit proxy stop ``` -`stop`、`restart` 以及任何自动重启都会中断已连接的客户端和进行中的请求;之后请重试这些请求。没有 `zcode-kit proxy` 的版本请使用相同的子命令运行 `node <安装目录>/proxy/zcode-proxy-manager.mjs`。 +`stop`、`restart` 以及任何自动重启都会中断已连接的客户端和进行中的请求;之后请重试这些请求。没有 `zcode-kit proxy` 的版本请使用相同的子命令运行 `node <安装目录>/proxy/zcode-proxy-manager.mjs`。一旦验证代理属于本安装,`start` 和 `status` 会输出连接详情(基础 URL、密钥、模型 ID);已停止的代理会报告为未运行,其配置值也会相应标注。 **挂起的代理:**只有在证明无响应的代理属于本 Kit 时,`start`、`restart` 和 `stop` 才会终止它:已超过 60 秒启动宽限期、启动时间与记录一致、命令行是 Kit 代理,并且连续 3 次健康检查(约 25 秒)失败。归属未知的进程绝不会被终止;命令会报告情况并停止。`zcode-kit doctor --fix` 会重新应用受管配置;如果代理未运行或经证明已挂起,也会以同样方式启动它。 @@ -155,12 +173,14 @@ zcode-kit auth status ``` - **找不到命令:**重新打开终端。对于正式版安装,请检查 `%LOCALAPPDATA%\Microsoft\WindowsApps`(Windows)或 `$HOME/.local/bin`(macOS/Linux)是否位于 PATH 中。 -- **模型没有回复:**检查 Desktop 登录状态和可用额度。如果助手不会自动启动代理,请手动启动。本地健康检查不能证明模型可访问。 +- **设置时跳过了助手:**下次回答 `y`,运行 `zcode-kit integrate <助手>`(多个助手用 `zcode-kit setup --harness <列表>`),或运行 `zcode-kit setup --reask` 对每个检测到的助手重新提问。没有终端时,用 `ZCODE_KIT_HARNESSES` 选择助手。 +- **手动配置客户端:**代理运行期间,`zcode-kit proxy status` 会输出基础 URL、密钥和模型 ID;完整密钥只在交互式终端显示(否则使用 `zcode-kit models --show-key`)。 +- **模型没有回复:**检查 Desktop 登录状态和可用额度。如果客户端不会自动启动代理,请手动启动。本地健康检查不能证明模型可访问。 - **代理未运行或无响应:**运行 `zcode-kit doctor --fix` 或 `zcode-kit proxy restart`。只会终止经证明属于本 Kit 的挂起代理;参阅上方手动管理代理部分。 - **OMP 自启动:**直接启动 OMP。设置过程固定原生 Node/Bun;扩展在新的子进程中执行预检查,而不是将 Kit 模块导入 OMP。失败时会报告不含秘密信息的错误类别;子进程最长运行 120 秒。修复所报告的原因后,等待该会话的 60 秒冷却时间再重试;可在同一会话中恢复。若运行时位置已变更,请重新运行 `zcode-kit setup --harness auto` 并重新加载扩展。不会干预占用端口的未知进程。 - **导入 Desktop 登录:**运行 `zcode-kit auth login zai --import`,导入 Desktop 0.16.9 当前激活的 `zai`/`start-plan` 登录;必须已明确配置计划。只要存在 `credentials.json`,就以它为准;凭据无效时不会静默回退到旧的 `config.json`。新版 `coding-plan` 登录应改用 `zcode-kit auth login zai` 进行常规 OAuth 登录;导入器不会创建或获取 API 密钥。 - **401 或端口被占用:**检查是否存在另一份 Kit。不要删除密钥,也不要终止不认识的进程。 -- **设置中途失败:**重试前先阅读输出中的回滚命令。此前的变更可能仍在。 +- **设置中途失败:**某个助手失败不会撤销其他助手;它自身的部分变更会被撤回,安装器会带着警告继续。重试前先阅读输出中的回滚命令。此前的变更可能仍在。 ## 使用真实项目数据之前 diff --git a/cli/account-setup.mjs b/cli/account-setup.mjs index a776a8f..35ee9bb 100644 --- a/cli/account-setup.mjs +++ b/cli/account-setup.mjs @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { createInterface } from "node:readline"; import { join } from "node:path"; import { commitFile } from "../lib/edit.mjs"; +import { askYesNo } from "./harness-consent.mjs"; export const ACCOUNT_ROTATOR_QUESTION = "Do you want to activate the Account Rotator feature?"; @@ -12,18 +12,9 @@ export function rotatorChoice(value) { throw new Error("Account Rotator choice must be y or n (--account-rotator or ZCODE_KIT_ACCOUNT_ROTATOR)."); } -export async function askAccountRotator({ input = process.stdin, output = process.stdout, env = process.env } = {}) { - if (!input.isTTY || !output.isTTY || (env.CI && !/^(0|false)$/i.test(env.CI))) return undefined; - const lines = createInterface({ input, output, terminal: true, prompt: `${ACCOUNT_ROTATOR_QUESTION} [y/n] ` }); - try { - lines.prompt(); - for await (const answer of lines) { - if (/^[yn]$/i.test(answer.trim())) return answer.trim().toLowerCase() === "y"; - output.write("Please answer y or n.\n"); - lines.prompt(); - } - return undefined; // Closed input must never count as consent. - } finally { lines.close(); } +/** Independent of the per-harness questions: same y/n rules, separate answer. */ +export function askAccountRotator(streams = {}) { + return askYesNo(ACCOUNT_ROTATOR_QUESTION, streams); } /** Record the config change in the existing setup transaction; keep YAML comments/policies. */ diff --git a/cli/adapters/aider.mjs b/cli/adapters/aider.mjs index 79fc585..fda3718 100644 --- a/cli/adapters/aider.mjs +++ b/cli/adapters/aider.mjs @@ -28,6 +28,11 @@ ZCODE_AIDER_DEFAULT_MODEL=openai/glm-5.3 return { changed: true }; }, + /** The env export lives in this installation's state: kit-owned by construction. */ + owned(ctx) { + return existsSync(join(ctx.generated, "aider-zcode.env")); + }, + verify(ctx) { return [{ name: "aider launcher env", ok: existsSync(join(ctx.generated, "aider-zcode.env")), detail: "generated/aider-zcode.env" }]; }, diff --git a/cli/adapters/claude-code.mjs b/cli/adapters/claude-code.mjs index 362a055..4cfc658 100644 --- a/cli/adapters/claude-code.mjs +++ b/cli/adapters/claude-code.mjs @@ -41,6 +41,11 @@ export default { return { changed: wrote.wrote }; }, + /** The generated settings file lives in this installation's state: kit-owned by construction. */ + owned(ctx) { + return existsSync(join(ctx.generated, "claude-zcode-settings.json")); + }, + verify(ctx) { const settingsPath = join(ctx.generated, "claude-zcode-settings.json"); return [{ name: "claude adapter artifact", ok: existsSync(settingsPath), detail: settingsPath }]; diff --git a/cli/adapters/cline.mjs b/cli/adapters/cline.mjs index ac288ad..2b66b2f 100644 --- a/cli/adapters/cline.mjs +++ b/cli/adapters/cline.mjs @@ -47,6 +47,11 @@ Start the proxy first: \`node proxy/zcode-proxy-manager.mjs start\` return { changed: true, manualConfirmationRequired: true }; }, + /** The values sheet lives in this installation's state: kit-owned by construction. */ + owned(ctx) { + return existsSync(join(ctx.generated, "cline-zcode-values.md")); + }, + verify(ctx) { return [{ name: "cline values sheet", ok: existsSync(join(ctx.generated, "cline-zcode-values.md")), detail: "generated — GUI entry still required" }]; }, diff --git a/cli/adapters/codex.mjs b/cli/adapters/codex.mjs index b120818..0439919 100644 --- a/cli/adapters/codex.mjs +++ b/cli/adapters/codex.mjs @@ -43,6 +43,11 @@ args = ["${mcpEntry}/mcp/zcode-harness-mcp/dist/index.js", "--stdio"] return { changed: wrote }; }, + /** The isolated CODEX_HOME lives in this installation's state: kit-owned by construction. */ + owned(ctx) { + return existsSync(join(ctx.generated, "codex-home", "config.toml")); + }, + verify(ctx) { return [{ name: "codex adapter artifact", ok: existsSync(join(ctx.generated, "codex-home", "config.toml")), detail: "generated/codex-home/config.toml" }]; }, diff --git a/cli/adapters/continue.mjs b/cli/adapters/continue.mjs index 7bc95dd..8f8afcc 100644 --- a/cli/adapters/continue.mjs +++ b/cli/adapters/continue.mjs @@ -150,6 +150,16 @@ export default { return { changed: wrote }; }, + /** Kit-owned and bound to this installation: the managed block embeds THIS copy's key. */ + owned(ctx) { + const configYaml = join(ctx.home, ".continue", "config.yaml"); + if (!existsSync(configYaml)) return false; + const text = readFileSync(configYaml, "utf8"); + const begin = text.indexOf(BLOCK_BEGIN); + const end = begin >= 0 ? text.indexOf(BLOCK_END, begin) : -1; + return begin >= 0 && end >= 0 && text.slice(begin, end).includes(`apiKey: ${JSON.stringify(ctx.key())}`); + }, + verify(ctx) { const configYaml = join(ctx.home, ".continue", "config.yaml"); if (!existsSync(configYaml)) return [{ name: "continue models registered", ok: null, detail: "not installed" }]; diff --git a/cli/adapters/goose.mjs b/cli/adapters/goose.mjs index 88f828b..dacba46 100644 --- a/cli/adapters/goose.mjs +++ b/cli/adapters/goose.mjs @@ -69,6 +69,13 @@ export default { return { changed: true }; }, + /** Kit-owned and bound to this installation: the auth helper points at this root's key resolver. */ + owned(ctx) { + const doc = readJsonSafe(join(providerDir(ctx.home), "zcode.json")); + const resolver = join(ctx.root, "proxy", "resolve-zcode-proxy-key.mjs").replace(/\\/g, "/"); + return doc?.name === "zcode" && doc?.auth?.command === "node" && Array.isArray(doc.auth.args) && doc.auth.args[0] === resolver; + }, + verify(ctx) { const file = join(providerDir(ctx.home), "zcode.json"); if (!existsSync(file)) return [{ name: "goose provider registered", ok: null, detail: "not installed" }]; diff --git a/cli/adapters/kilo-code.mjs b/cli/adapters/kilo-code.mjs index eadc70a..c4bbedf 100644 --- a/cli/adapters/kilo-code.mjs +++ b/cli/adapters/kilo-code.mjs @@ -51,6 +51,11 @@ Start the proxy first: \`node proxy/zcode-proxy-manager.mjs start\` return { changed: true, manualConfirmationRequired: true }; }, + /** The values sheet lives in this installation's state: kit-owned by construction. */ + owned(ctx) { + return existsSync(join(ctx.generated, "kilo-zcode-values.md")); + }, + verify(ctx) { return [{ name: "kilo values sheet", ok: existsSync(join(ctx.generated, "kilo-zcode-values.md")), detail: "generated — GUI entry still required" }]; }, diff --git a/cli/adapters/omp.mjs b/cli/adapters/omp.mjs index 41afd73..52c5ef0 100644 --- a/cli/adapters/omp.mjs +++ b/cli/adapters/omp.mjs @@ -310,6 +310,16 @@ export default { return { changed }; }, + /** Kit-owned and bound to this installation: the managed block embeds THIS copy's key. */ + owned(ctx) { + const modelsYml = join(ctx.home, ".omp", "agent", "models.yml"); + if (!existsSync(modelsYml)) return false; + const text = readFileSync(modelsYml, "utf8"); + if (!text.includes(MARKER_BEGIN) || !text.includes(MARKER_END)) return false; + const block = text.slice(text.indexOf(MARKER_BEGIN), text.indexOf(MARKER_END)); + return block.includes(`apiKey: ${JSON.stringify(ctx.key())}`); + }, + verify(ctx) { const agentDir = join(ctx.home, ".omp", "agent"); const checks = []; diff --git a/cli/adapters/opencode.mjs b/cli/adapters/opencode.mjs index 7fbd35f..b9b7792 100644 --- a/cli/adapters/opencode.mjs +++ b/cli/adapters/opencode.mjs @@ -94,6 +94,18 @@ export default { return { changed: wrote }; }, + /** Kit signature and this installation's proxy port (the entry carries no other identity). */ + owned(ctx) { + const target = configPath(ctx.home); + if (!existsSync(target)) return false; + try { + const current = parseJsonc(readFileSync(target, "utf8"))?.provider?.zcode; + return isKitOwned(current) && current.options.baseURL === `http://127.0.0.1:${ctx.port()}/v1`; + } catch { + return false; + } + }, + verify(ctx) { const target = configPath(ctx.home); if (!existsSync(target)) return [{ name: "opencode provider registered", ok: null, detail: "not installed" }]; diff --git a/cli/adapters/pi.mjs b/cli/adapters/pi.mjs index 33837be..fb72c9e 100644 --- a/cli/adapters/pi.mjs +++ b/cli/adapters/pi.mjs @@ -104,6 +104,18 @@ export default { return { changed: wrote }; }, + /** Kit-owned and bound to this installation: marker present and the key resolver points at this root. */ + owned(ctx) { + const modelsJson = join(ctx.home, ".pi", "agent", "models.json"); + if (!existsSync(modelsJson)) return false; + try { + const current = parseJsonc(readFileSync(modelsJson, "utf8"))?.providers?.zcode; + return !!current && current["x-zcode-agent-kit"]?.managed === true && current.apiKey === zcodeProvider(ctx).apiKey; + } catch { + return false; + } + }, + verify(ctx) { const modelsJson = join(ctx.home, ".pi", "agent", "models.json"); if (!existsSync(modelsJson)) return [{ name: "pi provider registered", ok: null, detail: "not installed" }]; diff --git a/cli/connection-details.mjs b/cli/connection-details.mjs new file mode 100644 index 0000000..a406de6 --- /dev/null +++ b/cli/connection-details.mjs @@ -0,0 +1,73 @@ +// Copyable connection details for manual client setup (no harness adapter +// involved). Values come from this installation's runtime files or the +// running proxy — never from defaults that may not match the instance. +// +// Secret handling: the local proxy key is printed in full only for a human +// at an interactive terminal (stdin and stdout are TTYs, no CI marker). Logs, +// CI, pipes, JSON and installer log files get a redaction marker; +// `zcode-kit models --show-key` remains the explicit opt-in for exporting the +// key to another program. +import { existsSync, readFileSync } from "node:fs"; + +export const DEFAULT_MODEL_IDS = ["glm-5.3", "glm-5.3-flash"]; +export const REDACTED_KEY = "(redacted: not an interactive terminal — print it with: zcode-kit models --show-key)"; + +export function shouldRevealKey({ stdin = process.stdin, stdout = process.stdout, env = process.env, json = false } = {}) { + if (json) return false; + if (stdout.isTTY !== true || stdin.isTTY !== true) return false; + return !(env.CI && !/^(0|false)$/i.test(env.CI)); +} + +function readConfig(configPath) { + try { + if (!configPath || !existsSync(configPath)) return null; + return readFileSync(configPath, "utf8"); + } catch { + return null; + } +} + +/** Model ids from the `models:` list of the proxy config; registry snapshot when absent or unreadable. */ +export function configuredModelIds(configPath) { + const text = readConfig(configPath); + if (text === null) return [...DEFAULT_MODEL_IDS]; + const block = text.match(/^models:[ \t]*\r?\n((?:[ \t]+-[ \t]*\S.*\r?\n?)+)/m); + if (!block) return [...DEFAULT_MODEL_IDS]; + const ids = [...block[1].matchAll(/^[ \t]+-[ \t]*["']?([A-Za-z0-9._-]+)["']?[ \t]*\r?$/gm)].map((m) => m[1]); + return ids.length ? ids : [...DEFAULT_MODEL_IDS]; +} + +/** Listener host and Responses-API switch from the proxy config (template defaults when absent). */ +export function configuredServer(configPath) { + const text = readConfig(configPath) ?? ""; + const host = text.match(/^server:[ \t]*\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+host:[ \t]*["']?([^"'\s]+)["']?/m)?.[1] ?? "127.0.0.1"; + const responses = text.match(/^responses:[ \t]*\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+enabled:[ \t]*(true|false)/m)?.[1]; + return { host, responsesEnabled: responses !== "false" }; +} + +export function isLoopbackHost(host) { + return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host); +} + +/** + * Plain-text lines (no colour, no emojis). `source` is "running" only when + * the caller verified the proxy answered as this installation's own. + */ +export function connectionDetailsLines({ port, key, models = DEFAULT_MODEL_IDS, source = "configured", reveal = false, host = "127.0.0.1", responsesEnabled = true, indent = " " }) { + const loopback = isLoopbackHost(host); + const base = `http://${loopback ? "127.0.0.1" : host}:${port}`; + const keyText = reveal && typeof key === "string" && key.length ? key : REDACTED_KEY; + const verified = source === "running"; + const openaiRoutes = responsesEnabled ? "POST /chat/completions, POST /responses, GET /models" : "POST /chat/completions, GET /models"; + const lines = [ + `${indent}Connection details (local ZCode proxy, ${verified ? "running and verified" : "from configuration; proxy not verified running"})`, + `${indent} OpenAI-compatible base URL: ${base}/v1 (${openaiRoutes})`, + `${indent} Anthropic-compatible base URL: ${base} (POST /v1/messages)`, + `${indent} API key (Bearer / x-api-key): ${keyText}`, + `${indent} Model IDs: ${models.join(", ")}`, + `${indent} Works with any OpenAI- or Anthropic-compatible client; no harness auto-configuration required.` + + (verified ? "" : " Start the proxy first: zcode-kit proxy start"), + ]; + if (!loopback) lines.push(`${indent} WARNING: server.host is ${host} — the proxy is meant to stay on loopback; do not expose it.`); + return lines; +} diff --git a/cli/harness-consent.mjs b/cli/harness-consent.mjs new file mode 100644 index 0000000..b67df18 --- /dev/null +++ b/cli/harness-consent.mjs @@ -0,0 +1,289 @@ +// Per-harness consent for `zcode-kit setup`. +// +// Setup never configures a harness silently: each detected harness gets its +// own question ("Configure ZCode as a provider with its supported models in +// ? [y/n]"), an explicit selection (`--harness a,b`, +// `ZCODE_KIT_HARNESSES=a,b`, `none`) counts as consent for exactly those ids, +// and a missing terminal is never consent. Decisions are stored one file per +// harness under `/generated/harness-choices/` through the setup +// transaction, so a rollback of the setup that recorded a decision removes +// that decision too, and update/repair paths refresh only harnesses the user +// consented to. A kit-owned integration that predates the questions (proven +// by the adapter's `owned(ctx)`) is refreshed on unattended runs but never +// turned into consent: the next interactive run still asks. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { commitFile, ensureDir } from "../lib/edit.mjs"; + +export const HARNESS_CHOICES_DIR = "harness-choices"; +export const HARNESS_SELECTION_ENV = "ZCODE_KIT_HARNESSES"; +export const NO_CONSENT_HINT = `no interactive consent (select explicitly: --harness or ${HARNESS_SELECTION_ENV}=)`; +export const ABORTED = "ABORTED"; + +export function harnessQuestion(label) { + return `Configure ZCode as a provider with its supported models in ${label}?`; +} + +export function isInteractive({ input = process.stdin, output = process.stdout, env = process.env } = {}) { + if (!input.isTTY || !output.isTTY) return false; + return !(env.CI && !/^(0|false)$/i.test(env.CI)); +} + +function abortError() { + const err = new Error("aborted by the user (Ctrl-C)"); + err.code = ABORTED; + return err; +} + +/** + * One terminal session for several y/n questions. A single readline + * interface owns stdin for the whole setup: lines typed ahead are queued and + * answer the following questions in order instead of being swallowed by a + * closed interface. `ask` resolves true/false for an explicit answer, or + * undefined when no interactive answer is possible: no TTY on either side, + * CI, or input closed before an answer (EOF must never count as consent). + * Ctrl-C rejects with an error whose `code` is ABORTED so the caller can stop + * asking instead of treating it as "no answer". + */ +export function createPrompter({ input = process.stdin, output = process.stdout, env = process.env } = {}) { + const interactive = isInteractive({ input, output, env }); + const queue = []; + const waiters = []; + let rl = null; + let closed = false; + let aborted = false; + function settleWaiters() { + while (waiters.length) { + const waiter = waiters.shift(); + if (aborted) waiter.reject(abortError()); else waiter.resolve(undefined); + } + } + function ensure() { + if (rl || closed) return; + rl = createInterface({ input, output, terminal: true }); + rl.on("line", (line) => { + const waiter = waiters.shift(); + if (waiter) waiter.resolve(line); else queue.push(line); + }); + rl.on("SIGINT", () => { + aborted = true; + queue.length = 0; + output.write("\n"); + const current = rl; + rl = null; + closed = true; + current.close(); + settleWaiters(); + }); + rl.on("close", () => { + closed = true; + rl = null; + settleWaiters(); + }); + } + function nextLine(promptText) { + if (aborted) return Promise.reject(abortError()); + if (queue.length) { + // Typed ahead: show the question with the answer it consumed. + const line = queue.shift(); + output.write(`${promptText}${line}\n`); + return Promise.resolve(line); + } + if (closed) return Promise.resolve(undefined); + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + rl.setPrompt(promptText); + rl.prompt(); + }); + } + return { + interactive, + get aborted() { return aborted; }, + async ask(question) { + if (!interactive) return undefined; + ensure(); + const promptText = `${question} [y/n] `; + for (;;) { + const line = await nextLine(promptText); + if (line === undefined) return undefined; // Closed input must never count as consent. + if (/^[yn]$/i.test(line.trim())) return line.trim().toLowerCase() === "y"; + output.write("Please answer y or n.\n"); + } + }, + close() { + closed = true; + if (rl) { const current = rl; rl = null; current.close(); } + settleWaiters(); + }, + }; +} + +/** Single y/n question on its own terminal session (see createPrompter). */ +export async function askYesNo(question, streams = {}) { + const prompter = createPrompter(streams); + try { + return await prompter.ask(question); + } finally { + prompter.close(); + } +} + +/** + * Explicit, documented harness selection for unattended runs. + * Returns undefined for auto-detection (default), `{ ids, none, source }` + * otherwise. Unknown ids throw (an unknown harness is an error, not a no-op). + */ +export function resolveHarnessSelection(flagValue, envValue, knownIds) { + const pick = (value, source) => { + const text = String(value).trim(); + if (!text || text === "auto") return undefined; + if (text === "none") return { ids: [], none: true, source }; + const ids = [...new Set(text.split(",").map((s) => s.trim()).filter(Boolean))]; + for (const id of ids) { + if (!knownIds.includes(id)) throw new Error(`unknown harness "${id}" in ${source === "flag" ? "--harness" : HARNESS_SELECTION_ENV}. Known: ${knownIds.join(", ")}, none (or auto for detection)`); + } + if (!ids.length) return undefined; + return { ids, none: false, source }; + }; + if (flagValue !== undefined && flagValue !== "auto") return pick(flagValue, "flag"); + if (envValue !== undefined) return pick(envValue, "env"); + return undefined; +} + +export function harnessChoicesDir(ctx) { + return join(ctx.generated, HARNESS_CHOICES_DIR); +} + +/** + * Stored decisions, one JSON file per harness. Unreadable or malformed files + * fail closed: their ids are reported in `unreadable` and count as undecided + * without any existing-integration shortcut, and nothing is recorded for them. + */ +export function readHarnessChoices(ctx) { + const dir = harnessChoicesDir(ctx); + const result = { harnesses: {}, unreadable: [], errors: [] }; + if (!existsSync(dir)) return result; + let names = []; + try { + names = readdirSync(dir).filter((name) => /^[a-z0-9-]+\.json$/.test(name)); + } catch (err) { + result.errors.push(`${dir}: ${err.message} — decisions unreadable`); + result.unreadable.push("*"); + return result; + } + for (const name of names) { + const id = name.slice(0, -".json".length); + const file = join(dir, name); + try { + const entry = JSON.parse(readFileSync(file, "utf8")); + if (!entry || entry.schema !== 1 || (entry.decision !== "configured" && entry.decision !== "skipped")) throw new Error("unsupported format"); + result.harnesses[id] = { decision: entry.decision, source: String(entry.source ?? "unknown"), decidedAt: String(entry.decidedAt ?? "") }; + } catch (err) { + result.unreadable.push(id); + result.errors.push(`${file}: ${err.message} — fix or remove the file; ${id} counts as undecided until then`); + } + } + return result; +} + +export function isUnreadableChoice(choices, id) { + return choices.unreadable.includes("*") || choices.unreadable.includes(id); +} + +/** Record one decision through the transaction (rollback of that setup removes it again). */ +export function recordHarnessChoice(ctx, tx, choices, id, decision, source, now = () => new Date().toISOString()) { + if (ctx.dryRun) return choices; + if (isUnreadableChoice(choices, id)) return choices; // never overwrite what could not be read + const previous = choices.harnesses[id]; + if (previous && previous.decision === decision && previous.source === source) return choices; + const entry = { schema: 1, harness: id, decision, source, decidedAt: now() }; + ensureDir(ctx, harnessChoicesDir(ctx)); + commitFile(ctx, tx, join(harnessChoicesDir(ctx), `${id}.json`), JSON.stringify(entry, null, 2) + "\n"); + choices.harnesses[id] = { decision, source, decidedAt: entry.decidedAt }; + return choices; +} + +/** A kit-owned integration bound to THIS installation (adapter proof, never a shape guess). */ +export function ownedIntegration(adapter, ctx) { + if (typeof adapter?.owned !== "function") return false; + try { + return adapter.owned(ctx) === true; + } catch { + return false; + } +} + +/** + * Decide one harness. `ask` resolves the y/n answer (undefined = no answer, + * rejects with code ABORTED on Ctrl-C). + * Returns { action: "configure" | "refresh" | "skip" | "ignore", source, + * reason, record, consent, note }: + * - explicit selection: listed ids are configured, `none` skips detected + * ids, everything else is left alone; + * - a stored decision stands: "configured" is refreshed without asking, + * "skipped" stays skipped until `--harness`, `integrate` or `--reask` + * (which asks again only on a terminal); + * - an unreadable decision file leaves the harness undecided and is never + * overwritten; without a terminal it is skipped; + * - an owned kit integration without a decision is asked about on a + * terminal and only refreshed (no consent recorded, no MCP) otherwise; + * - otherwise the harness is asked; y configures, n skips, no answer skips + * without changing the stored decision. + * `consent` marks decisions that authorize MCP registration for the harness. + */ +export async function decideHarness({ id, label, detected, stored, unreadable = false, owned = false, explicit, reask = false, ask }) { + const via = (source) => (source === "flag" ? "--harness" : HARNESS_SELECTION_ENV); + // Re-asking needs a terminal: without one, stored decisions keep standing. + const reasking = reask && typeof ask === "function"; + if (explicit) { + if (explicit.ids.includes(id)) return { action: "configure", source: explicit.source, reason: `selected via ${via(explicit.source)}`, record: true, consent: true }; + if (explicit.none && detected) return { action: "skip", source: explicit.source, reason: `${via(explicit.source)}=none`, record: true, consent: false }; + return { action: "ignore", source: explicit.source, reason: "not selected", record: false, consent: false }; + } + if (!detected) return { action: "ignore", source: "detect", reason: "not detected", record: false, consent: false }; + if (unreadable) { + const answer = ask ? await ask(harnessQuestion(label)) : undefined; + if (answer === true) return { action: "configure", source: "interactive", reason: "answered y (decision file unreadable — not recorded)", record: false, consent: true }; + return { action: "skip", source: "none", reason: answer === false ? "answered n (decision file unreadable — not recorded)" : "decision file unreadable — fix or remove it", record: false, consent: false }; + } + if (stored === "configured" && !reasking) return { action: "configure", source: "stored", reason: "previously configured", record: false, consent: true }; + if (stored === "skipped" && !reasking) return { action: "skip", source: "stored", reason: `previously skipped (change with: zcode-kit integrate ${id}, setup --harness ${id}, or setup --reask)`, record: false, consent: false }; + if (!ask) { + if (owned) return { action: "refresh", source: "existing", reason: "existing kit integration refreshed; consent not recorded (answer once on a terminal or select it explicitly)", record: false, consent: false }; + return { action: "skip", source: "none", reason: NO_CONSENT_HINT, record: false, consent: false }; + } + const answer = await ask(harnessQuestion(label)); + if (answer === true) return { action: "configure", source: "interactive", reason: "answered y", record: true, consent: true, note: owned ? "existing kit integration found; y keeps it current" : undefined }; + if (answer === false) return { action: "skip", source: "interactive", reason: "answered n", record: true, consent: false }; + if (owned) return { action: "refresh", source: "existing", reason: "existing kit integration refreshed; no answer, consent not recorded", record: false, consent: false }; + return { action: "skip", source: "none", reason: NO_CONSENT_HINT, record: false, consent: false }; +} + +/** + * Ids that repair paths (doctor --fix, update) may touch without asking: + * stored consent, or a kit integration owned by this installation. An + * unreadable decision file blocks the repair of that harness. + */ +export function consentedForRepair(ctx, ids, detected, adapters, choices = readHarnessChoices(ctx)) { + return ids.filter((id) => { + if (!detected[id]) return false; + if (isUnreadableChoice(choices, id)) return false; + const stored = choices.harnesses[id]?.decision; + if (stored === "configured") return true; + if (stored === "skipped") return false; + const adapter = adapters[id]; + return adapter ? ownedIntegration(adapter, ctx) : false; + }); +} + +/** Doctor view of a harness that is neither explicitly requested nor consented. */ +export function consentStatus(ctx, id, adapter, detected, choices = readHarnessChoices(ctx)) { + if (!detected) return { verify: false, detail: "not detected — skipped" }; + if (isUnreadableChoice(choices, id)) return { verify: false, detail: "decision file unreadable — fix or remove it (see zcode-kit setup)" }; + const stored = choices.harnesses[id]?.decision; + if (stored === "configured") return { verify: true }; + if (stored === "skipped") return { verify: false, detail: "not configured (your choice) — zcode-kit integrate " + id + " to change" }; + if (ownedIntegration(adapter, ctx)) return { verify: true }; + return { verify: false, detail: `not configured (no consent yet) — answer y in zcode-kit setup or run zcode-kit integrate ${id}` }; +} diff --git a/cli/setup-output.mjs b/cli/setup-output.mjs index 39303e2..3e04a6e 100644 --- a/cli/setup-output.mjs +++ b/cli/setup-output.mjs @@ -22,5 +22,11 @@ export function setupOutput(ctx, compact = false) { ok(text) { if (compact) console.log(` ${paint("32", "[OK]")} ${text}`); }, skip(text) { if (compact) console.log(` [SKIP] ${text}`); }, warn(text) { console.log(` ${paint("33", "[WARN]")} ${text}`); }, + // Per-harness results are shown in both modes: the answer to a consent + // question deserves an immediate, visible outcome. + result(tag, text) { + const label = tag === "ok" ? paint("32", "[OK]") + " " : tag === "skip" ? "[SKIP]" : paint("31", "[FAIL]"); + console.log(` ${label} ${text}`); + }, }; } diff --git a/cli/zcode-kit.mjs b/cli/zcode-kit.mjs index 3d5a60a..e212f5e 100644 --- a/cli/zcode-kit.mjs +++ b/cli/zcode-kit.mjs @@ -21,9 +21,11 @@ // zcode-kit uninstall // // Exit codes: 0 ok · 1 checks failed · 2 runtime error · 3 port/foreign conflict · -// 4 safe-start refused (lock/ownership) · 5 auth/identity failure. Unknown -// harness names are errors, not no-ops. `setup` exits 0 once the integration -// is saved even if the optional live smoke request fails (it prints a warning). +// 4 safe-start refused (lock/ownership) · 5 auth/identity failure · 130 setup +// aborted with Ctrl-C. Unknown harness names are errors, not no-ops. `setup` +// exits 0 once the consented integrations are saved even if the optional live +// smoke request fails (it prints a warning); it exits 1 when one harness +// failed while the others were configured. import { beginTransaction, acquireLock, releaseLock, rollbackTransaction, listTransactions } from "../lib/transaction.mjs"; import { detectHarnesses } from "../lib/detect.mjs"; import { createCtx, bootstrap, kitRoot } from "./context.mjs"; @@ -47,6 +49,11 @@ import { commitFile, ensureDir } from "../lib/edit.mjs"; import { launchHarness } from "./launch.mjs"; import { askAccountRotator, configureAccountRotator, restartForAccountChange, rotatorChoice } from "./account-setup.mjs"; import { setupOutput } from "./setup-output.mjs"; +import { + ABORTED, HARNESS_SELECTION_ENV, NO_CONSENT_HINT, consentStatus, consentedForRepair, createPrompter, decideHarness, + isUnreadableChoice, ownedIntegration, readHarnessChoices, recordHarnessChoice, resolveHarnessSelection, +} from "./harness-consent.mjs"; +import { ACCOUNT_ROTATOR_QUESTION } from "./account-setup.mjs"; import { mkdtempSync, readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, normalize } from "node:path"; @@ -72,7 +79,7 @@ function parseArgs(argv) { if (a.startsWith("--")) { const eq = a.indexOf("="); if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1); - else if (["fix", "json", "dry-run", "no-mcp", "yes", "help", "show-key", "installer", "verbose", "import", "paste", "replace", "live"].includes(a.slice(2))) flags[a.slice(2)] = true; + else if (["fix", "json", "dry-run", "no-mcp", "yes", "help", "show-key", "installer", "verbose", "import", "paste", "replace", "live", "reask"].includes(a.slice(2))) flags[a.slice(2)] = true; else flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; } else positional.push(a); } @@ -113,8 +120,8 @@ async function main() { function usage(code) { console.log(`zcode-kit — local ZCode provider for your own agent harnesses - zcode-kit setup [--harness auto|omp,pi,...] bootstrap + integrate (auto = detect) - zcode-kit integrate [--dry-run] + zcode-kit setup [--harness auto|omp,pi,...|none] bootstrap + one y/n question per detected harness + zcode-kit integrate [--dry-run] explicit consent for one harness zcode-kit run -- launch harness wired to ZCode zcode-kit doctor [--fix] [--harness ] [--json] zcode-kit status | models [--json] [--show-key] | usage --json @@ -126,11 +133,18 @@ function usage(code) { zcode-kit accounts doctor [--json] | accounts quota | accounts health [--json] zcode-kit update [--version vX.Y.Z] | rollback [tx-id] | uninstall (update keeps .proxykey, proxy/config.yaml, logs, backups, generated and node_modules) - (setup: --harness limits adapters AND MCP registration; --no-mcp skips registration) + (setup: auto asks "Configure ZCode as a provider ... in ? [y/n]" per detected harness; + without a terminal, undecided harnesses are skipped — never configured silently) + (setup: --harness or ${HARNESS_SELECTION_ENV}= selects harnesses without questions (unattended); + none skips all; the selection also limits MCP registration; --no-mcp skips registration; + answers are remembered under generated/harness-choices/ — --reask asks again) (setup: --account-rotator y|n for unattended installs; --verbose for full installer output) + (proxy start/status and setup print the connection details for manual client setup; + the key is shown in full only on an interactive terminal — export: zcode-kit models --show-key) -Exit codes: 0 ok · 1 checks failed · 2 runtime error · 3 port/foreign conflict · -4 safe-start refused · 5 auth/identity failure +Exit codes: 0 ok · 1 checks failed (setup: at least one harness failed, the others are configured) · +2 runtime error · 3 port/foreign conflict · 4 safe-start refused · 5 auth/identity failure · +130 setup aborted with Ctrl-C (answers already given were applied) Harnesses: ${ADAPTER_IDS.join(", ")}`); return code; @@ -183,10 +197,16 @@ async function cmdSetup() { const explicitChoice = rotatorChoice(flags["account-rotator"] ?? process.env.ZCODE_KIT_ACCOUNT_ROTATOR); const harnessArg = flags.harness ?? "auto"; const detected = detectHarnesses(ctx.home); - const targets = harnessArg === "auto" - ? ADAPTER_IDS.filter((id) => detected[id]) - : String(harnessArg).split(",").map((s) => s.trim()).filter(Boolean); - for (const t of targets) requireHarness(t); + // Consent model: `--harness ` / ZCODE_KIT_HARNESSES= is an + // explicit, unattended-safe selection; `auto` asks one y/n question per + // detected harness on a terminal and skips undecided harnesses otherwise. + let explicit; + try { + explicit = resolveHarnessSelection(harnessArg, process.env[HARNESS_SELECTION_ENV], ADAPTER_IDS); + } catch (err) { + console.error(err.message); + return 2; + } assertNotCheckoutWrite(); ensureState(ctx); @@ -197,30 +217,123 @@ async function cmdSetup() { ctx.tx = tx; let txId = null; let accountChange = false; + const outcome = { configured: [], skipped: [], failed: [] }; + let undecided = false; + let abortedByUser = false; + // One terminal session for every question (harnesses, then the rotator): + // answers typed ahead stay in order instead of being lost between prompts. + const prompter = createPrompter(); + const mcpBridgeAvailable = !flags["no-mcp"] && existsSync(join(ctx.mcpDir, "dist", "index.js")); try { ui.step("Runtime and configuration"); bootstrap(ctx, (...args) => ui.detail(...args)); ui.ok("Runtime ready"); ui.step("Assistant integrations"); + const detectedIds = ADAPTER_IDS.filter((id) => detected[id]); ui.detail( - "detected harnesses: " + - (Object.entries(detected).filter(([, v]) => v).map(([k]) => k).join(", ") || "none") + - " — adapters run only for detected or explicitly requested harnesses", + "detected harnesses: " + (detectedIds.join(", ") || "none") + + " — adapters run only after consent (y answer, stored decision, explicit selection) or to refresh a kit-owned integration", ); - for (const id of targets) { + const choices = readHarnessChoices(ctx); + for (const error of choices.errors) ui.warn(error); + const interactive = !explicit && prompter.interactive; + if (!explicit && !interactive && detectedIds.length) { + console.log(` No interactive terminal: harnesses without a saved decision are skipped (select them with --harness or ${HARNESS_SELECTION_ENV}=).`); + } + if (!explicit && !detectedIds.length) { + console.log(" No supported assistant detected. The connection details below work with any OpenAI- or Anthropic-compatible client."); + } + const mcpIds = []; + for (const id of ADAPTER_IDS) { const { default: adapter } = await loadAdapter(id); - ui.detail(`== ${id}: ${adapter.label} ==`); - let skipped = false, warning = false; - adapter.apply(ctx, tx, (m) => { ui.detail(m); skipped ||= /\bskipped\b/i.test(m); warning ||= /\bWARN:/i.test(m); }); - if (warning) ui.warn(`${adapter.label}: review the warning above`); - else if (skipped) ui.skip(adapter.label); - else ui.ok(adapter.label); + if (abortedByUser) { + if (detected[id] || explicit?.ids.includes(id)) { + outcome.skipped.push({ id, label: adapter.label, reason: "aborted" }); + ui.result("skip", `${adapter.label} — aborted`); + } + continue; + } + const owned = detected[id] ? ownedIntegration(adapter, ctx) : false; + // The question text is fixed; what a y implies beyond the provider + // entry is said before it (existing integration, MCP bridge). + const ask = interactive ? (question) => { + if (owned) console.log(` note: an existing kit integration for ${adapter.label} was found; y keeps it current, n leaves it untouched.`); + if (mcpBridgeAvailable && (id === "omp" || id === "claude-code")) console.log(` note: y also registers the kit's MCP bridge "zcode-harness" for ${adapter.label} (skip with --no-mcp).`); + return prompter.ask(question); + } : null; + let decision; + try { + decision = await decideHarness({ + id, + label: adapter.label, + detected: Boolean(detected[id]), + stored: choices.harnesses[id]?.decision, + unreadable: isUnreadableChoice(choices, id), + owned, + explicit, + reask: flags.reask === true, + ask, + }); + } catch (err) { + if (err?.code !== ABORTED) throw err; + // Ctrl-C: stop asking; what was already applied stays (it was consented). + abortedByUser = true; + outcome.skipped.push({ id, label: adapter.label, reason: "aborted" }); + ui.result("skip", `${adapter.label} — aborted`); + continue; + } + if (decision.action === "ignore") continue; + if (decision.action === "skip") { + if (decision.record) recordHarnessChoice(ctx, tx, choices, id, "skipped", decision.source); + if (decision.reason === NO_CONSENT_HINT) undecided = true; + outcome.skipped.push({ id, label: adapter.label, reason: decision.reason }); + ui.result("skip", `${adapter.label} — ${decision.reason}`); + continue; + } + ui.detail(`== ${id}: ${adapter.label} (${decision.reason}) ==`); + let skipped = false, warning = false, manual = false; + const savepoint = tx.savepoint(); + try { + const applied = adapter.apply(ctx, tx, (m) => { ui.detail(m); skipped ||= /\bskipped\b/i.test(m); warning ||= /\bWARN:/i.test(m); }); + manual = applied?.manualConfirmationRequired === true; + if (decision.record) recordHarnessChoice(ctx, tx, choices, id, "configured", decision.source); + } catch (err) { + // One harness must not take the others down, and a half-applied + // integration must not stay behind: undo this harness's own writes. + const undone = tx.restoreSince(savepoint); + const note = undone.conflicts.length + ? ` (left in place, check manually: ${undone.conflicts.join(", ")})` + : undone.restored.length + undone.removed.length ? " (its partial changes were undone)" : ""; + outcome.failed.push({ id, label: adapter.label, error: err.message + note }); + ui.result("fail", `${adapter.label} — ${err.message}${note}`); + continue; + } + if (skipped) { + outcome.skipped.push({ id, label: adapter.label, reason: "nothing to configure yet (see the setup log)" }); + ui.result("skip", `${adapter.label} — nothing to configure yet (see the setup log)`); + continue; + } + const suffix = decision.action === "refresh" ? " — existing kit integration refreshed (no consent recorded)" + : manual ? " — values prepared; enter them in the extension UI" + : warning ? " — review the warning above" : ""; + outcome.configured.push({ id, label: adapter.label, refreshed: decision.action === "refresh", manual }); + ui.result("ok", adapter.label + suffix); + if (decision.consent) mcpIds.push(id); } - await integrateMcp(tx, detected, targets, (...args) => ui.detail(...args)); + // MCP registration follows consent, never a refresh of a pre-existing integration. + await integrateMcp(tx, detected, mcpIds, (...args) => ui.detail(...args)); ui.step("Account Rotator"); console.log("\n Keep authorized logins as separate encrypted accounts."); console.log(" New logins are saved automatically while the feature is enabled."); - const choice = explicitChoice ?? await askAccountRotator(); + let choice = explicitChoice; + if (choice === undefined && !abortedByUser) { + try { + choice = await prompter.ask(ACCOUNT_ROTATOR_QUESTION); + } catch (err) { + if (err?.code !== ABORTED) throw err; + abortedByUser = true; + } + } if (choice === undefined) { console.log(" Account Rotator setting unchanged (no interactive answer)."); console.log(" Enable later: zcode-kit accounts enable"); @@ -231,6 +344,7 @@ async function cmdSetup() { if (choice && result.accountCount === 0) console.log(" Add your first account: zcode-kit auth login zai"); } } finally { + prompter.close(); let finishErr = null; try { txId = tx.finish(); } catch (err) { finishErr = err; } releaseLock(join(BACKUP_DIR, ".setup-lock")); @@ -239,12 +353,38 @@ async function cmdSetup() { } if (accountChange) await restartForAccountChange(ctx); console.log("\n Configuration saved."); + console.log(` Assistants: ${outcome.configured.length} configured, ${outcome.skipped.length} skipped, ${outcome.failed.length} failed.`); + for (const entry of outcome.failed) console.log(` failed: ${entry.label} — ${entry.error}`); + if (undecided) console.log(" Undecided harnesses can be configured later: zcode-kit setup --harness , or zcode-kit integrate ."); + if (abortedByUser) console.log(" Aborted by the user: remaining questions were skipped; nothing beyond the answers given was configured."); ui.step("Connection check"); - const smoke = await setupSmoke(ctx); - if (smoke.code) ui.warn(smoke.detail); - else console.log(` [${smoke.cause === "skipped" ? "SKIP" : "OK"}] ${smoke.detail}`); + if (abortedByUser) { + console.log(" [SKIP] aborted by the user"); + } else { + const smoke = await setupSmoke(ctx); + if (smoke.code) ui.warn(smoke.detail); + else console.log(` [${smoke.cause === "skipped" ? "SKIP" : "OK"}] ${smoke.detail}`); + } + // Connection details for manual client setup: "running" only after the + // manager's authenticated identity check, model ids from the live instance. + // A kit layout without the proxy manager (minimal fixtures, tooling + // checkouts) has no proxy component to describe. + console.log(""); + const managerPath = join(ROOT, "proxy", "zcode-proxy-manager.mjs"); + let manager = null; + if (existsSync(managerPath)) { + try { + const mod = await import(pathToFileURL(managerPath).href); + if (typeof mod.createManager === "function") manager = mod.createManager({ root: ROOT, home: ctx.home }); + } catch (err) { + ui.warn(`connection details unavailable: the proxy manager could not be loaded (${err.message})`); + } + } + if (manager) await manager.printConnectionDetails(await manager.healthIdentify() === "ours" ? "running" : "configured"); + else console.log(" Connection details unavailable: no proxy manager in this kit layout (see zcode-kit proxy status after a full install)."); if (compact) console.log(`\n Setup log: ${ui.logPath}`); - return 0; + if (abortedByUser) return 130; + return outcome.failed.length ? 1 : 0; } function sameInstallationPath(candidate, expected) { @@ -349,6 +489,9 @@ async function cmdIntegrate() { bootstrap(ctx); console.log(`== integrate ${id}: ${adapter.label} ==`); adapter.apply(ctx, tx, (m) => console.log(m)); + // An explicit integrate command is consent for this harness: later + // setup/update/repair runs keep it current without asking again. + recordHarnessChoice(ctx, tx, readHarnessChoices(ctx), id, "configured", "integrate"); } finally { let finishErr = null; try { txId = tx.finish(); } catch (err) { finishErr = err; } @@ -373,8 +516,11 @@ async function cmdDoctor() { const detected = detectHarnesses(ctx.home); if (flags.fix) { assertNotCheckoutWrite(); - const targets = ids.filter(id => flags.harness || detected[id]); - const adapters = await Promise.all(targets.map(async id => (await loadAdapter(id)).default)); + const loaded = Object.fromEntries(await Promise.all(ids.map(async id => [id, (await loadAdapter(id)).default]))); + // Repairs never widen consent: without --harness only harnesses the user + // consented to (stored decision or an existing kit integration) are re-applied. + const targets = flags.harness ? ids : consentedForRepair(ctx, ids, detected, loaded); + const adapters = targets.map(id => loaded[id]); const repaired = await repairManaged(ctx, adapters, flags.json ? () => {} : console.log); if (repaired.id && !flags.json) console.log(`transaction ${repaired.id} recorded — undo with: zcode-kit rollback ${repaired.id}`); } @@ -411,11 +557,17 @@ async function cmdDoctor() { process.stdout.write(core.stdout ?? ""); } + const choices = readHarnessChoices(ctx); for (const id of ids) { const { default: adapter } = await loadAdapter(id); - if (!flags.harness && !detected[id]) { - checks.push({ name: `${id}: integration`, ok: null, detail: "not detected — skipped" }); - continue; + if (!flags.harness) { + // A harness the user declined (or never answered for) is not a failed + // check: only consented or kit-owned integrations are verified. + const status = consentStatus(ctx, id, adapter, Boolean(detected[id]), choices); + if (!status.verify) { + checks.push({ name: `${id}: integration`, ok: null, detail: status.detail }); + continue; + } } for (const c of adapter.verify(ctx)) checks.push({ name: `${id}: ${c.name}`, ok: c.ok, detail: c.detail ?? "" }); } @@ -751,7 +903,7 @@ function stopProxyIfRunning() { } function finishUpdate(harnessArgs, txId = null) { - console.log("update: re-applying integrations for detected harnesses..."); + console.log("update: refreshing consented integrations (stored answers are respected; new harnesses are asked only on a terminal)..."); // Audit H3: explicitly running `update` IS the opt-in the checkout-write // guard asks for. Fresh process so the updated modules (not the ones // already loaded by this process) apply the integrations. @@ -760,10 +912,14 @@ function finishUpdate(harnessArgs, txId = null) { stdio: "inherit", env: { ...process.env, ZCODE_KIT_ALLOW_CHECKOUT: "1" }, }); - if (res.status !== 0) { + // Exit 1 = one or more harnesses failed but the kit itself is fine: the + // proxy must still come back. Anything else is a real setup failure. + const harnessFailures = res.status === 1; + if (res.status !== 0 && !harnessFailures) { console.error("update: setup failed — the kit files are updated; inspect the output above and rerun `zcode-kit setup`."); return res.status ?? 2; } + if (harnessFailures) console.error("update: some assistants could not be configured (see the summary above); the proxy is started anyway."); // update stopped the proxy before mutating files, so it must come back // here: an update that returns with a dead proxy is not done. The // manager `start` is idempotent (already-running exits 0) and safe-starts @@ -774,7 +930,7 @@ function finishUpdate(harnessArgs, txId = null) { if (!existsSync(managerPath)) { console.log("update: no proxy manager in this kit layout — skipping the proxy start."); if (txId) console.log(`transaction ${txId} recorded — undo with: zcode-kit rollback ${txId}`); - return 0; + return harnessFailures ? 1 : 0; } console.log("update: starting the proxy on the updated code..."); const started = spawnSync(process.execPath, [managerPath, "start"], { stdio: "inherit" }); @@ -783,7 +939,7 @@ function finishUpdate(harnessArgs, txId = null) { return started.status ?? 2; } if (txId) console.log(`transaction ${txId} recorded — undo with: zcode-kit rollback ${txId}`); - return 0; + return harnessFailures ? 1 : 0; } // ---------------------------------------------------------------- rollback diff --git a/harnesses/README.de.md b/harnesses/README.de.md index e429079..d3b7223 100644 --- a/harnesses/README.de.md +++ b/harnesses/README.de.md @@ -3,8 +3,7 @@ > Übersetzung des englischen Originals; bei Abweichungen gilt das englische README. -Der Kern des Kits ist harness-neutral: ein lokaler HTTP-Proxy auf -`http://127.0.0.1:8457` mit drei Standardformaten: +Der Kern des Kits ist harness-neutral: ein lokaler HTTP-Proxy mit drei Standardformaten. Die Beispiele verwenden den Standardport `8457`; `zcode-kit proxy status` zeigt Port und Verbindungsdaten deiner Installation: | Endpoint | Format | Nutzung | |---|---|---| @@ -19,11 +18,9 @@ Authentifizierung: `Authorization: Bearer `. Quellcode-Checkouts liegt `.proxykey` im Kit-Verzeichnis; bei npm liegt der Schlüssel im separaten Zustand dieser Installation außerhalb von `node_modules`. -## Von `zcode-kit setup` automatisch eingerichtet (nur für erkannte Harnesses) +## Einrichtung durch `zcode-kit setup` (erkannte Harnesses, nur mit Zustimmung) -`zcode-kit setup --harness auto` erkennt installierte Harnesses und richtet -**nur diese** ein. Bei ausschließlich OMP werden keine Claude-/Codex- -Konfigurationen oder Wrapper-Dateien erzeugt. +`zcode-kit setup --harness auto` erkennt installierte Harnesses und fragt für jeden ohne gespeicherte Entscheidung: "Configure ZCode as a provider with its supported models in ? [y/n]". Ein `y` richtet diesen Harness ein; `n` überspringt ihn und lässt seine Dateien unberührt, und ohne Terminal wird jeder unentschiedene Harness übersprungen. Strg-C beendet die Fragen (Exit-Code 130); was du bereits mit `y` beantwortet hast, bleibt eingerichtet. Bei ausschließlich OMP werden keine Claude-/Codex-Konfigurationen oder Wrapper-Dateien erzeugt. Entscheidungen werden pro Harness als Datei unter `generated/harness-choices/` gespeichert und gehören zur Setup-Transaktion (ein Rollback entfernt sie wieder); `zcode-kit update` und `zcode-kit doctor --fix` wenden nur zugestimmte Integrationen erneut an (ein `y`, eine ausdrückliche Auswahl oder `zcode-kit integrate `), und ein gespeichertes `n` gilt, bis `--harness`, `integrate` oder `zcode-kit setup --reask` (fragt im Terminal erneut) es ändern. Eine Integration, die das Kit vor der Frage angelegt hat, wird bei unbeaufsichtigten Läufen aktualisiert, aber nie zur Zustimmung umgedeutet. Für unbeaufsichtigte Läufe wählst du Harnesses mit `--harness omp,codex` oder `ZCODE_KIT_HARNESSES=omp,codex` (`none` überspringt alle erkannten); unbekannte IDs sind Fehler. Die MCP-Registrierung folgt derselben Zustimmung. Ein fehlgeschlagener Harness stoppt die anderen nicht: Seine eigenen Teiländerungen werden zurückgenommen, die Zusammenfassung führt ihn als fehlgeschlagen, und das Setup endet mit Exit-Code 1 (der Installer fährt mit einer Warnung fort). | Harness | Mechanismus | Eingriff in bestehende Config | |---|---|---| @@ -37,7 +34,7 @@ Konfigurationen oder Wrapper-Dateien erzeugt. | Goose | `%APPDATA%/Block/goose/config/custom_providers/zcode.json` (Windows) oder `~/.config/goose/custom_providers/zcode.json` (macOS/Linux) | Credential über dokumentierten `auth.command`-Helper (Kit-Key-Resolver, ohne Shell) | | Cline | `generated/cline-zcode-values.md` — **manual-confirmation-required** | Kit fasst VS-Code-State nie an; Werte einmalig in der UI eintragen | | Kilo Code | `generated/kilo-zcode-values.md` — **manual-confirmation-required** | Custom-Provider (Anthropic Messages) in der UI; kilo.jsonc schreibt das Kit bewusst nicht | -| MCP-fähige Harnesses | stdio-Server `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: Eintrag in `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (nur wenn erkannt); Codex: im isolierten Home. MCP allein zählt NICHT als Modellintegration | +| MCP-fähige Harnesses | stdio-Server `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: Eintrag in `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (nur wenn erkannt und zugestimmt); Codex: im isolierten Home. MCP allein zählt NICHT als Modellintegration | Pfade unter `generated/` beziehen sich auf den Kit-Zustand: bei Release-/ Quellcode-Installationen im Kit-Verzeichnis, bei npm im separaten Zustandsverzeichnis. @@ -53,6 +50,8 @@ OMP direkt starten, etwa mit `omp --model zcode/glm-5.3-flash --thinking low`, n ## Manuelles Anbinden (jeder OpenAI-/Anthropic-fähige Client) +Das ist ein gleichwertiger Einstieg, kein Notbehelf. Der Kit-Proxy muss laufen, und ZCode braucht einen gültigen Login. `zcode-kit proxy start` (auch wenn der Proxy bereits läuft) und `zcode-kit proxy status` geben die aktuellen Basis-URLs, den lokalen Schlüssel und die Modell-IDs aus. Ist der Proxy nicht als laufend verifiziert, sind die Werte als aus der Konfiguration stammend gekennzeichnet, und die Ausgabe sagt `Start the proxy first: zcode-kit proxy start`. Der vollständige Schlüssel erscheint nur in einem interaktiven Terminal; sonst `zcode-kit models --show-key`. Ersetze den Beispielport unten durch den vom Befehl angezeigten Port. + ```yaml # OpenAI-Format base_url: http://127.0.0.1:8457/v1 diff --git a/harnesses/README.es.md b/harnesses/README.es.md index 97025c3..24a0211 100644 --- a/harnesses/README.es.md +++ b/harnesses/README.es.md @@ -4,8 +4,7 @@ > Traducción del original en inglés; ante cualquier discrepancia manda el > original en inglés. -El núcleo del kit es neutral respecto al harness: un proxy HTTP local en -`http://127.0.0.1:8457` con tres formatos estándar: +El núcleo del kit es neutral respecto al harness: un proxy HTTP local con tres formatos estándar. Los ejemplos usan el puerto predeterminado `8457`; `zcode-kit proxy status` muestra el puerto y los datos de conexión de tu instalación: | Endpoint | Formato | Uso | |---|---|---| @@ -20,11 +19,9 @@ Autenticación: `Authorization: Bearer `. copias del código, `.proxykey` está en el directorio del kit; con npm se guarda fuera de `node_modules`, en el estado exclusivo de esa instalación. -## Configurado automáticamente por `zcode-kit setup` (solo para harnesses detectados) +## Configurado por `zcode-kit setup` (harnesses detectados, solo con consentimiento) -`zcode-kit setup --harness auto` detecta los harnesses instalados y configura -**solo esos**. Si únicamente se detecta OMP, no se crean configuraciones ni -wrappers de Claude/Codex. +`zcode-kit setup --harness auto` detecta los harnesses instalados y pregunta, por cada uno sin decisión guardada, "Configure ZCode as a provider with its supported models in ? [y/n]". Un `y` configura ese harness; `n` lo omite y deja sus archivos intactos, y sin terminal se omite todo harness sin decidir. Ctrl-C detiene las preguntas (código de salida 130); lo que ya respondiste con `y` queda configurado. Si únicamente se detecta OMP, no se crean configuraciones ni wrappers de Claude/Codex. Las decisiones se guardan en un archivo por harness en `generated/harness-choices/` y forman parte de la transacción de configuración (un rollback las elimina de nuevo); `zcode-kit update` y `zcode-kit doctor --fix` solo vuelven a aplicar integraciones consentidas (un `y`, una selección explícita o `zcode-kit integrate `), y un `n` guardado se respeta hasta que `--harness`, `integrate` o `zcode-kit setup --reask` (vuelve a preguntar en una terminal) lo cambien. Una integración que el kit creó antes de preguntar se actualiza en ejecuciones desatendidas, pero nunca se convierte en consentimiento. Para ejecuciones desatendidas selecciona los harnesses con `--harness omp,codex` o `ZCODE_KIT_HARNESSES=omp,codex` (`none` omite todos los detectados); los id desconocidos son errores. El registro MCP sigue el mismo consentimiento. Un harness que falla no detiene a los demás: sus propios cambios parciales se deshacen, el resumen lo lista como fallido y la configuración termina con código 1 (el instalador continúa con una advertencia). | Harness | Mecanismo | Impacto en la config existente | |---|---|---| @@ -38,7 +35,7 @@ wrappers de Claude/Codex. | Goose | `%APPDATA%/Block/goose/config/custom_providers/zcode.json` (Windows) o `~/.config/goose/custom_providers/zcode.json` (macOS/Linux) | credencial vía el helper documentado `auth.command` (resolver de clave del kit, sin shell) | | Cline | `generated/cline-zcode-values.md` — **manual-confirmation-required** | el kit nunca toca el estado de VS Code; introduce los valores una vez en la UI | | Kilo Code | `generated/kilo-zcode-values.md` — **manual-confirmation-required** | provider personalizado (Anthropic messages) en la UI; el kit deliberadamente no escribe kilo.jsonc | -| Harnesses con MCP | servidor stdio `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: entrada en `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (solo si se detecta); Codex: dentro del home aislado. MCP por sí solo NO cuenta como integración de modelo | +| Harnesses con MCP | servidor stdio `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: entrada en `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (solo si se detecta y se consiente); Codex: dentro del home aislado. MCP por sí solo NO cuenta como integración de modelo | Las rutas `generated/` indican el estado del kit: dentro del directorio del kit para versiones publicadas/código fuente y en un directorio aparte para npm. @@ -54,6 +51,8 @@ Ejecuta OMP directamente, por ejemplo con `omp --model zcode/glm-5.3-flash --thi ## Conexión manual (cualquier cliente compatible OpenAI/Anthropic) +Es una vía de entrada equivalente, no un plan B. El proxy del kit debe estar en ejecución y ZCode necesita una sesión válida. `zcode-kit proxy start` (también cuando el proxy ya está en ejecución) y `zcode-kit proxy status` imprimen las URL base actuales, la clave local y los ID de modelo. Cuando el proxy no se verifica en ejecución, los valores se etiquetan como procedentes de la configuración y la salida dice `Start the proxy first: zcode-kit proxy start`. La clave completa solo aparece en una terminal interactiva; en otro caso usa `zcode-kit models --show-key`. Sustituye el puerto de ejemplo de abajo por el que muestra el comando. + ```yaml # Formato OpenAI base_url: http://127.0.0.1:8457/v1 diff --git a/harnesses/README.ja.md b/harnesses/README.ja.md index 15f0f7c..bb27357 100644 --- a/harnesses/README.ja.md +++ b/harnesses/README.ja.md @@ -3,8 +3,7 @@ > 本ドキュメントは英語原文の翻訳です。相違がある場合は英語版が正となります。 -キットの中核はハーネス非依存です。`http://127.0.0.1:8457` で待ち受ける -ローカル HTTP プロキシが、3 つの標準形式を提供します: +キットの中核はハーネス非依存です。ローカル HTTP プロキシが 3 つの標準形式を提供します。以下の例はデフォルトポート `8457` を使います。実際のポートと接続情報は `zcode-kit proxy status` が表示します: | エンドポイント | 形式 | 用途 | |---|---|---| @@ -19,11 +18,9 @@ チェックアウトでは kit 内に `.proxykey` を置きます。npm インストールでは `node_modules` の外にあるインストール固有の状態ディレクトリに保存します。 -## `zcode-kit setup` による自動セットアップ(検出されたハーネスのみ) +## `zcode-kit setup` によるセットアップ(検出されたハーネス、同意がある場合のみ) -`zcode-kit setup --harness auto` はインストール済みのハーネスを検出し、 -**その対象だけ**を設定します。OMP しかない場合、Claude/Codex の設定や -生成ラッパーは作られません。 +`zcode-kit setup --harness auto` はインストール済みのハーネスを検出し、保存済みの決定がないハーネスごとに "Configure ZCode as a provider with its supported models in ? [y/n]" と質問します。`y` でそのハーネスを設定し、`n` はスキップしてファイルに触れません。ターミナルがなければ未決定のハーネスはすべてスキップされます。Ctrl-C で質問を中止できます(終了コード 130)。すでに `y` と答えたものは設定されたままです。OMP しかない場合、Claude/Codex の設定や生成ラッパーは作られません。決定はハーネスごとに 1 ファイルとして `generated/harness-choices/` に保存され、セットアップのトランザクションの一部です(ロールバックで再び削除されます)。`zcode-kit update` と `zcode-kit doctor --fix` は同意済みの統合(`y`、明示的な選択、または `zcode-kit integrate `)だけを再適用し、保存された `n` は `--harness`、`integrate`、または `zcode-kit setup --reask`(ターミナルで再質問)で変えるまで尊重されます。質問より前に kit が作成した統合は無人実行でも更新されますが、同意と見なされることはありません。無人実行では `--harness omp,codex` または `ZCODE_KIT_HARNESSES=omp,codex` でハーネスを選択します(`none` は検出されたすべてをスキップ)。不明な id はエラーです。MCP の登録も同じ同意に従います。1 つのハーネスが失敗しても他は止まりません。そのハーネス自身の部分的な書き込みは元に戻され、サマリーに失敗として表示され、セットアップは終了コード 1 で終わります(インストーラーは警告を出して続行します)。 | ハーネス | 仕組み | 既存 config への影響 | |---|---|---| @@ -37,7 +34,7 @@ | Goose | `%APPDATA%/Block/goose/config/custom_providers/zcode.json`(Windows)または `~/.config/goose/custom_providers/zcode.json`(macOS/Linux) | 認証情報は文書化された `auth.command` ヘルパー経由(kit のキーリゾルバ、シェルなし) | | Cline | `generated/cline-zcode-values.md` — **manual-confirmation-required** | kit は VS Code の状態に一切触れない。UI で一度だけ値を入力する | | Kilo Code | `generated/kilo-zcode-values.md` — **manual-confirmation-required** | UI でカスタム provider(Anthropic messages)を設定。kilo.jsonc は意図的に書かない | -| MCP 対応ハーネス | stdio サーバー `zcode-harness`(`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP:`~/.omp/agent/mcp.json` にエントリー。Claude Code:`claude mcp add`(検出時のみ)。Codex:隔離 home 内。MCP 単体はモデル統合としてカウントされない | +| MCP 対応ハーネス | stdio サーバー `zcode-harness`(`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP:`~/.omp/agent/mcp.json` にエントリー。Claude Code:`claude mcp add`(検出され同意した場合のみ)。Codex:隔離 home 内。MCP 単体はモデル統合としてカウントされない | `generated/` 以下のパスは kit の状態を指します。リリース版/ソースでは kit ディレクトリ内、npm では別の状態ディレクトリ内です。 @@ -53,6 +50,8 @@ OMP は `omp --model zcode/glm-5.3-flash --thinking low` などで直接起動 ## 手動での接続(OpenAI/Anthropic 対応の任意のクライアント) +これは同等の入口であり、代替手段ではありません。kit のプロキシが起動しており、ZCode に有効なログインが必要です。`zcode-kit proxy start`(すでに起動中の場合も)と `zcode-kit proxy status` は現在のベース URL、ローカルキー、モデル ID を表示します。プロキシの起動が検証できない場合、値は設定由来としてラベル付けされ、出力に `Start the proxy first: zcode-kit proxy start` と表示されます。完全なキーは対話的なターミナルでのみ表示されます。それ以外では `zcode-kit models --show-key` を使ってください。以下の例のポートは、コマンドが表示するポートに置き換えてください。 + ```yaml # OpenAI 形式 base_url: http://127.0.0.1:8457/v1 diff --git a/harnesses/README.md b/harnesses/README.md index ff94daa..cc6b511 100644 --- a/harnesses/README.md +++ b/harnesses/README.md @@ -1,8 +1,10 @@ # Harness Adapters — How Other Agent CLIs Use the Local Proxy **English (original)** · [Deutsch](README.de.md) · [Español](README.es.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) -The core of the kit is harness-neutral: a local HTTP proxy at -`http://127.0.0.1:8457` with three standard formats: +The core of the kit is harness-neutral: a local HTTP proxy with three +standard formats. The examples below use the default port `8457`; +`zcode-kit proxy status` prints the port and connection values of your +installation: | Endpoint | Format | Used by | |---|---|---| @@ -17,11 +19,29 @@ Authentication: `Authorization: Bearer `. `.proxykey` lives in the kit directory; npm installs keep it in a separate installation-specific state directory, outside `node_modules`. -## Set up automatically by `zcode-kit setup` (only for detected harnesses) - -`zcode-kit setup --harness auto` detects installed harnesses and configures -**only those**. A user with only OMP gets no Claude/Codex configuration or -generated wrapper files. +## Set up by `zcode-kit setup` (detected harnesses, only with consent) + +`zcode-kit setup --harness auto` detects installed harnesses and asks, for each +one without a stored decision, "Configure ZCode as a provider with its +supported models in ? [y/n]". A `y` configures that harness; `n` +skips it and leaves its files untouched, and without a terminal every +undecided harness is skipped. Ctrl-C stops the questions (exit code 130); +what you already answered `y` to stays configured. A user with only OMP gets +no Claude/Codex configuration or generated wrapper files. + +Decisions are stored one file per harness under `generated/harness-choices/` +and are part of the setup transaction (a rollback removes them again). +`zcode-kit update` and `zcode-kit doctor --fix` re-apply only consented +integrations (a `y`, an explicit selection or `zcode-kit integrate +`); a stored `n` is respected until `--harness`, `integrate` or +`zcode-kit setup --reask` (asks again on a terminal) changes it. An +integration the kit created before it asked is refreshed on unattended runs +but never turned into consent. For unattended runs select harnesses with +`--harness omp,codex` or `ZCODE_KIT_HARNESSES=omp,codex` (`none` skips all +detected harnesses); unknown ids are errors. MCP registration follows the +same consent. One failing harness does not stop the others: its own partial +writes are undone, the summary lists it as failed and setup exits 1 (the +installer continues with a warning). | Harness | Mechanism | Impact on existing config | |---|---|---| @@ -35,7 +55,7 @@ generated wrapper files. | Goose | `%APPDATA%/Block/goose/config/custom_providers/zcode.json` (Windows) or `~/.config/goose/custom_providers/zcode.json` (macOS/Linux) | credential via documented `auth.command` helper (kit key resolver, no shell) | | Cline | `generated/cline-zcode-values.md` — **manual-confirmation-required** | the kit never touches VS Code state; enter the values once in the UI | | Kilo Code | `generated/kilo-zcode-values.md` — **manual-confirmation-required** | custom provider (Anthropic messages) in the UI; the kit deliberately does not write kilo.jsonc | -| MCP-capable harnesses | stdio server `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: entry in `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (only if detected); Codex: inside the isolated home. MCP alone does NOT count as model integration | +| MCP-capable harnesses | stdio server `zcode-harness` (`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP: entry in `~/.omp/agent/mcp.json`; Claude Code: `claude mcp add` (only if detected and consented); Codex: inside the isolated home. MCP alone does NOT count as model integration | Paths under `generated/` refer to kit state: the kit directory for release installs/source checkouts, or the separate state directory for npm installs. @@ -51,6 +71,15 @@ Run OMP directly, for example `omp --model zcode/glm-5.3-flash --thinking low`, ## Wiring a client manually (any OpenAI-/Anthropic-capable client) +This is an equal entry point, not a fallback. The kit proxy must be running +and ZCode needs a valid login. `zcode-kit proxy start` (also when the proxy is +already running) and `zcode-kit proxy status` print the current base URLs, +local key and model IDs. While the proxy is not verified running, the values +are labelled as coming from configuration and the output says `Start the +proxy first: zcode-kit proxy start`. The full key appears only in an +interactive terminal; otherwise use `zcode-kit models --show-key`. Replace the +example port below with the port the command shows. + ```yaml # OpenAI format base_url: http://127.0.0.1:8457/v1 diff --git a/harnesses/README.zh-CN.md b/harnesses/README.zh-CN.md index dc1bbb9..99b64e0 100644 --- a/harnesses/README.zh-CN.md +++ b/harnesses/README.zh-CN.md @@ -3,8 +3,7 @@ > 本文为英文原文的翻译;如有出入,以英文原版为准。 -kit 的核心与具体 harness 无关:一个监听 `http://127.0.0.1:8457` 的本地 -HTTP 代理,支持三种标准格式: +kit 的核心与具体 harness 无关:一个提供三种标准格式的本地 HTTP 代理。下面的示例使用默认端口 `8457`;你安装的实际端口和连接信息由 `zcode-kit proxy status` 显示: | 端点 | 格式 | 用途 | |---|---|---| @@ -18,10 +17,9 @@ HTTP 代理,支持三种标准格式: `zcode-kit setup` 在本地生成密钥。正式版和源码检出将 `.proxykey` 保存在 Kit 目录;npm 安装则保存在 `node_modules` 之外的专用状态目录。 -## 由 `zcode-kit setup` 自动配置(仅针对检测到的 harness) +## 由 `zcode-kit setup` 配置(检测到的 harness,仅在同意后) -`zcode-kit setup --harness auto` 检测已安装的 harness,并**只配置这些**。 -如果只装了 OMP,就不会创建 Claude/Codex 的配置或生成包装器文件。 +`zcode-kit setup --harness auto` 检测已安装的 harness,并对每个没有已保存决定的 harness 提问:"Configure ZCode as a provider with its supported models in ? [y/n]"。回答 `y` 才配置该 harness;`n` 会跳过它且不触碰其文件,没有终端时所有未决定的 harness 都会被跳过。按 Ctrl-C 会停止提问(退出码 130);已经回答 `y` 的仍保持配置。如果只装了 OMP,就不会创建 Claude/Codex 的配置或生成包装器文件。决定以每个 harness 一个文件的形式保存在 `generated/harness-choices/` 中,并属于设置事务的一部分(回滚会再次删除它们);`zcode-kit update` 和 `zcode-kit doctor --fix` 只重新应用已同意的集成(回答 `y`、明确选择或 `zcode-kit integrate `),已保存的 `n` 会一直被遵守,直到通过 `--harness`、`integrate` 或 `zcode-kit setup --reask`(在终端中重新提问)更改。Kit 在提问之前创建的集成会在无人值守运行时刷新,但绝不会被视为同意。无人值守运行时用 `--harness omp,codex` 或 `ZCODE_KIT_HARNESSES=omp,codex` 选择 harness(`none` 跳过所有检测到的 harness);未知 id 视为错误。MCP 注册遵循同样的同意。某个 harness 失败不会阻止其他 harness:它自身的部分写入会被撤销,汇总中将其列为失败,设置以退出码 1 结束(安装器会带着警告继续)。 | Harness | 机制 | 对现有配置的影响 | |---|---|---| @@ -35,7 +33,7 @@ HTTP 代理,支持三种标准格式: | Goose | `%APPDATA%/Block/goose/config/custom_providers/zcode.json`(Windows)或 `~/.config/goose/custom_providers/zcode.json`(macOS/Linux) | 凭据通过文档化的 `auth.command` 助手(kit 密钥解析器,无 shell) | | Cline | `generated/cline-zcode-values.md` — **manual-confirmation-required** | kit 绝不触碰 VS Code 状态;在 UI 中手动录入一次即可 | | Kilo Code | `generated/kilo-zcode-values.md` — **manual-confirmation-required** | 在 UI 中配置自定义 provider(Anthropic messages);kit 有意不写 kilo.jsonc | -| 支持 MCP 的 harness | stdio 服务器 `zcode-harness`(`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP:写入 `~/.omp/agent/mcp.json`;Claude Code:`claude mcp add`(仅在检测到时);Codex:隔离 home 内。仅 MCP 不算作模型集成 | +| 支持 MCP 的 harness | stdio 服务器 `zcode-harness`(`node mcp/zcode-harness-mcp/dist/index.js --stdio`) | OMP:写入 `~/.omp/agent/mcp.json`;Claude Code:`claude mcp add`(仅在检测到且已同意时);Codex:隔离 home 内。仅 MCP 不算作模型集成 | `generated/` 下的路径指向 Kit 状态:正式版/源码安装位于 Kit 目录中;npm 安装位于单独的状态目录中。 @@ -51,6 +49,8 @@ HTTP 代理,支持三种标准格式: ## 手动接入(任何支持 OpenAI/Anthropic 的客户端) +这是同等的入口,而不是备选方案。Kit 代理必须在运行,且 ZCode 需要有效登录。`zcode-kit proxy start`(包括代理已在运行时)和 `zcode-kit proxy status` 会输出当前的基础 URL、本地密钥和模型 ID。当代理未被验证为正在运行时,这些值会标注为来自配置,输出中会提示 `Start the proxy first: zcode-kit proxy start`。完整密钥只会在交互式终端中显示;其他情况请使用 `zcode-kit models --show-key`。请把下面示例中的端口替换为命令显示的端口。 + ```yaml # OpenAI 格式 base_url: http://127.0.0.1:8457/v1 diff --git a/install.ps1 b/install.ps1 index cbfa804..d9e0563 100644 --- a/install.ps1 +++ b/install.ps1 @@ -167,8 +167,13 @@ $bunExe = @(Get-Command bun -CommandType Application -ErrorAction Stop)[0].Sourc Write-InstallStep "[4/4] Configuring your workspace" Push-Location $InstallDir try { + # One y/n question per detected assistant, then the Account Rotator question, + # read from the console; without a terminal, undecided assistants are skipped. node cli/zcode-kit.mjs setup --harness auto --installer - if ($LASTEXITCODE -ne 0) { throw "setup failed (exit $LASTEXITCODE) - see output above" } + if ($LASTEXITCODE -eq 1) { + # The kit is installed; one or more assistants failed (see the summary). + Write-Host ' [WARN] some assistants could not be configured; see the summary above (the kit itself is installed)' + } elseif ($LASTEXITCODE -ne 0) { throw "setup failed (exit $LASTEXITCODE) - see output above" } } finally { Pop-Location } @@ -194,6 +199,7 @@ $bunExe = @(Get-Command bun -CommandType Application -ErrorAction Stop)[0].Sourc Write-Host ' zcode-kit auth login zai Sign in / add an account' Write-Host ' zcode-kit accounts View saved accounts' Write-Host ' zcode-kit doctor Check warnings and model access' + Write-Host ' zcode-kit proxy status Show connection details (base URL, key, models) for manual client setup' Write-Host '' } finally { $env:PATH = $originalPath diff --git a/install.sh b/install.sh index 4d1c21d..ecd0533 100644 --- a/install.sh +++ b/install.sh @@ -160,13 +160,22 @@ printf '%s\n' "$BUN_BIN" > "$INSTALL_DIR/.bun-path" cd "$INSTALL_DIR" ok "Application files installed" step "[4/4] Configuring your workspace" -# curl | sh consumes stdin. Read answers from the controlling terminal instead. +# curl | sh consumes stdin. Read answers (one y/n question per detected +# assistant, then the Account Rotator question) from the controlling terminal +# instead; without one, undecided assistants are skipped, never configured. run_setup() { node cli/zcode-kit.mjs setup --harness auto --installer; } +setup_status=0 if [ -t 1 ] && [ -r /dev/tty ]; then - run_setup < /dev/tty || die "setup failed; see the diagnostics above" 1 -elif ! run_setup; then - die "setup failed; see the diagnostics above" 1 + run_setup < /dev/tty || setup_status=$? +else + run_setup || setup_status=$? fi +case "$setup_status" in + 0) : ;; + # Exit 1: the kit is installed, one or more assistants failed (see the summary). + 1) printf ' [WARN] some assistants could not be configured; see the summary above (the kit itself is installed)\n' ;; + *) die "setup failed; see the diagnostics above" 1 ;; +esac # User-scope `zcode-kit` command in ~/.local/bin (conventionally on PATH). # Delete the file (or run zcode-kit uninstall) to undo. @@ -188,4 +197,5 @@ printf ' --------------------------------------------\n' printf ' %-27s %s\n' 'zcode-kit auth login zai' 'Sign in / add an account' printf ' %-27s %s\n' 'zcode-kit accounts' 'View saved accounts' printf ' %-27s %s\n' 'zcode-kit doctor' 'Check warnings and model access' +printf ' %-27s %s\n' 'zcode-kit proxy status' 'Show connection details (base URL, key, models) for manual client setup' printf '\n' diff --git a/lib/transaction.mjs b/lib/transaction.mjs index 8ae4b61..cfa791a 100644 --- a/lib/transaction.mjs +++ b/lib/transaction.mjs @@ -192,6 +192,49 @@ export class Transaction { return this; } + /** Number of recorded ops — a savepoint for restoreSince(). */ + savepoint() { + return this.ops.length; + } + + /** + * Undo the ops recorded after a savepoint (a harness whose adapter threw + * halfway must not leave a half integration in the committed setup). Only + * files whose current content is provably the kit's own write are touched: + * a created file is removed when it still matches the journalled write + * hash, a modified file is restored from its backup when it matches one of + * the journalled write hashes; an untouched file needs nothing. Anything + * else is left in place and reported. Externals are kept (their undo hint + * stays visible). Returns { restored, removed, conflicts }. + */ + restoreSince(index) { + const result = { restored: [], removed: [], conflicts: [] }; + const undone = this.ops.slice(index); + const kept = []; + for (const op of undone) { + if (op.kind === "external") { kept.push(op); continue; } + const hashes = [op.expectedHash, ...(op.expectedHashes ?? [])].filter(Boolean); + const exists = existsSync(op.target); + if (op.kind === "create") { + if (!exists) continue; + if (hashes.includes(sha256File(op.target))) { unlinkSync(op.target); result.removed.push(op.target); } + else { result.conflicts.push(op.target); kept.push(op); } + continue; + } + if (!exists || hashes.includes(sha256File(op.target))) { + restoreFromBackup(this.backupDir, this.id, op); + result.restored.push(op.target); + } else if (sha256File(op.target) !== op.preHash) { + result.conflicts.push(op.target); + kept.push(op); + } + // preHash match: the kit never wrote it — nothing to undo + } + this.ops = [...this.ops.slice(0, index), ...kept]; + this.persistJournal(); + return result; + } + /** * AUD-004 write-ahead journal: every op is persisted to an in-progress * manifest BEFORE the mutation happens (backup first, then journal — so a diff --git a/package.json b/package.json index dc8332f..bca69c8 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test tests/account-setup.test.mjs tests/transaction.test.mjs tests/setup-regressions.test.mjs tests/manager-safety.test.mjs tests/cli-kit.test.mjs tests/release-marker.test.mjs tests/installer.test.mjs tests/continue-compatibility.test.mjs tests/auto-heal.test.mjs tests/golden-path.test.mjs tests/postinstall-hint.test.mjs tests/registry-version.test.mjs tests/proxy-env.test.mjs tests/adapter-audit.test.mjs tests/cli-audit.test.mjs tests/installer-audit.test.mjs tests/manager-audit.test.mjs tests/omp-audit.test.mjs tests/process-audit.test.mjs tests/release-audit.test.mjs tests/state-audit.test.mjs tests/transaction-audit.test.mjs", + "test": "node --test tests/account-setup.test.mjs tests/harness-consent.test.mjs tests/connection-details.test.mjs tests/transaction.test.mjs tests/setup-regressions.test.mjs tests/manager-safety.test.mjs tests/cli-kit.test.mjs tests/release-marker.test.mjs tests/installer.test.mjs tests/continue-compatibility.test.mjs tests/auto-heal.test.mjs tests/golden-path.test.mjs tests/postinstall-hint.test.mjs tests/registry-version.test.mjs tests/proxy-env.test.mjs tests/adapter-audit.test.mjs tests/cli-audit.test.mjs tests/installer-audit.test.mjs tests/manager-audit.test.mjs tests/omp-audit.test.mjs tests/process-audit.test.mjs tests/release-audit.test.mjs tests/state-audit.test.mjs tests/transaction-audit.test.mjs", "test:proxy": "cd zcode-proxy-src && bun test", "test:mcp": "cd mcp/zcode-harness-mcp && npm test", "test:all": "npm run test && npm run test:proxy && npm run test:mcp", diff --git a/proxy/zcode-proxy-manager.mjs b/proxy/zcode-proxy-manager.mjs index aad0bf5..371a26c 100644 --- a/proxy/zcode-proxy-manager.mjs +++ b/proxy/zcode-proxy-manager.mjs @@ -36,6 +36,8 @@ import { createCtx } from "../cli/context.mjs"; import { logHeal } from "../cli/heal.mjs"; import { processCommandLine, resolveBun } from "../lib/process.mjs"; import { proxyEnv } from "../lib/proxy-env.mjs"; +import { configuredModelIds, configuredServer, connectionDetailsLines, shouldRevealKey } from "../cli/connection-details.mjs"; +import { readHarnessChoices } from "../cli/harness-consent.mjs"; // ------------------------------------------------------------------ factory const MANAGER_PATH = fileURLToPath(import.meta.url); @@ -862,10 +864,57 @@ try { } catch (err) { console.log(` quota: unavailable (${err.message})`); } + console.log(""); + await printConnectionDetails("running"); + } else if (state === "down") { + // Not running: configured values are still useful for manual client + // setup, but they are labelled as unverified — never as "running". + console.log(""); + await printConnectionDetails("configured"); + } else if (state === "foreign") { + console.log(` connection details withheld: port ${portOrNull()} is answered by a service that is not this kit's proxy`); + } else if (state === "nokey") { + console.log(` connection details unavailable: ${KEY_FILE} missing — run setup first`); } return state === "ours" ? 0 : 1; } + /** + * Connection details for manual client setup. `source` "running" is only + * passed by callers that verified /health answered as ours; model ids then + * come from the live /v1/models list, otherwise from the config file. The + * key is read, never generated or rotated here. + */ + async function connectionDetails(source) { + let models = configuredModelIds(CONFIG); + if (source === "running") { + try { + const res = await fetch(`${base()}/v1/models`, { headers: { Authorization: `Bearer ${readKey()}` }, signal: AbortSignal.timeout(5000) }); + const j = await res.json(); + const ids = (j?.data ?? []).map((m) => m?.id).filter((id) => typeof id === "string" && id.length > 0); + if (res.ok && ids.length) models = ids; + } catch { + // config list stays + } + } + const server = configuredServer(CONFIG); + return connectionDetailsLines({ port: loadPort(), key: readKey(), models, source, reveal: shouldRevealKey(), host: server.host, responsesEnabled: server.responsesEnabled }); + } + + async function printConnectionDetails(source) { + let lines; + try { + // "running" is re-proven right before printing: a proxy that stopped + // between the start/status check and now must not be shown as verified. + if (source === "running" && await healthIdentify(2500) !== "ours") source = "configured"; + lines = await connectionDetails(source); + } catch (err) { + console.log(` connection details unavailable: ${err.message}`); + return; + } + for (const line of lines) console.log(line); + } + /** /health `details` (newer proxies only); null when absent or unreadable. */ async function healthDetails() { try { @@ -1005,9 +1054,17 @@ try { // OMP checks only apply when OMP is actually installed here; a Codex-only // user must not get FAIL lines about OMP. const ompInstalled = !!OMP_AGENT && existsSync(join(OMP_AGENT, "models.yml")); - if (ompInstalled) { + // Only a consented or kit-managed OMP integration is checked: a declined + // or never-answered harness is a choice, not a failed check. + const ompChoice = readHarnessChoices({ generated: GENERATED }).harnesses.omp?.decision; + const ompManaged = (() => { + try { return readFileSync(join(OMP_AGENT, "models.yml"), "utf8").includes("# >>> zcode-kit (managed block)"); } catch { return false; } + })(); + if (ompInstalled && (ompChoice === "configured" || (ompChoice !== "skipped" && ompManaged))) { add("omp provider registered", ompHasZcode(), "zcode block in ~/.omp/agent/models.yml"); add("extension installed", existsSync(OMP_AGENT + "/extensions/zcode-proxy-autostart.ts"), "~/.omp/agent/extensions/zcode-proxy-autostart.ts"); + } else if (ompInstalled) { + add("omp integration", null, ompChoice === "skipped" ? "not configured (your choice) — zcode-kit integrate omp to change" : "not configured (no consent yet) — answer y in zcode-kit setup or run zcode-kit integrate omp"); } else { add("omp integration", null, "OMP not installed — skipped"); } @@ -1094,7 +1151,7 @@ try { return { start, stop, restart, respawn, status, doctor, logs, healthIdentify, readPidFile, verifyOwnProcess, proveHungOwn, - pidAlive, processStartMs, killOwned, lastRecovery: () => lastRecovery, + pidAlive, processStartMs, killOwned, lastRecovery: () => lastRecovery, connectionDetails, printConnectionDetails, LOG_FILE, PID_FILE, CONFIG, KEY_FILE, LOG_DIR, RESPAWN_FILE, }; } @@ -1105,9 +1162,21 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] async function main() { const cmd = process.argv[2] ?? "help"; switch (cmd) { - case "start": process.exit(await m.start()); break; + // After a successful start (including "already running") the operator + // gets the connection details of the verified instance. + case "start": { + const code = await m.start(); + if (code === 0) { console.log(""); await m.printConnectionDetails("running"); } + process.exit(code); + break; + } case "stop": process.exit(await m.stop()); break; - case "restart": process.exit(await m.restart()); break; + case "restart": { + const code = await m.restart(); + if (code === 0) { console.log(""); await m.printConnectionDetails("running"); } + process.exit(code); + break; + } case "status": process.exit(await m.status()); break; case "doctor": process.exit(await m.doctor()); break; case "logs": process.exit(m.logs(Number(process.argv[3] ?? 40) || 40)); break; diff --git a/setup.mjs b/setup.mjs index a6471c1..c7db194 100644 --- a/setup.mjs +++ b/setup.mjs @@ -31,7 +31,10 @@ if (mode === "--postinstall-hint") { } else { const only = argv.find((a) => a.startsWith("--only=")); const args = ["setup"]; - if (only) args.push(`--harness=${only.slice(7)}`); + // Legacy `mcp` token: MCP registration follows the selected harnesses and + // is not a harness id itself. + const harnesses = only ? only.slice(7).split(",").map((s) => s.trim()).filter((s) => s && s !== "mcp") : null; + if (only && harnesses.length) args.push(`--harness=${harnesses.join(",")}`); const res = spawnSync(process.execPath, [kit, ...args], { stdio: "inherit" }); process.exit(res.status ?? 1); } diff --git a/tests/connection-details.test.mjs b/tests/connection-details.test.mjs new file mode 100644 index 0000000..683d599 --- /dev/null +++ b/tests/connection-details.test.mjs @@ -0,0 +1,176 @@ +// Connection details for manual client setup: formatter redaction rules and +// the manager's status/start output against mock proxies (synthetic keys only). +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import http from "node:http"; +import { DEFAULT_MODEL_IDS, REDACTED_KEY, configuredModelIds, configuredServer, connectionDetailsLines, isLoopbackHost, shouldRevealKey } from "../cli/connection-details.mjs"; +import { createManager } from "../proxy/zcode-proxy-manager.mjs"; + +const KEY = "kit-test-key-Synthetic123"; + +test("formatter: URLs derive from the port, the key is shown only when reveal is true, output is plain text", () => { + const revealed = connectionDetailsLines({ port: 18457, key: KEY, models: ["glm-5.3", "glm-5.3-flash"], source: "running", reveal: true }); + const text = revealed.join("\n"); + assert.match(text, /OpenAI-compatible base URL:\s+http:\/\/127\.0\.0\.1:18457\/v1/); + assert.match(text, /Anthropic-compatible base URL: http:\/\/127\.0\.0\.1:18457 /); + assert.match(text, /POST \/v1\/messages/); assert.match(text, /POST \/responses/); + assert.ok(text.includes(`API key (Bearer / x-api-key): ${KEY}`)); + assert.match(text, /Model IDs:\s+glm-5\.3, glm-5\.3-flash/); + assert.match(text, /running and verified/); + assert.doesNotMatch(text, /Start the proxy first/); + const redacted = connectionDetailsLines({ port: 8457, key: KEY, source: "configured", reveal: false }).join("\n"); + assert.ok(!redacted.includes(KEY), "key never printed without reveal"); + assert.ok(redacted.includes(REDACTED_KEY)); + assert.match(redacted, /from configuration; proxy not verified running/); + assert.match(redacted, /Start the proxy first: zcode-kit proxy start/); + assert.match(redacted, /glm-5\.3, glm-5\.3-flash/, "default model ids"); + for (const line of [...revealed, ...redacted.split("\n")]) { + assert.doesNotMatch(line, /\x1b\[/, "no ANSI escapes"); + assert.doesNotMatch(line, /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}️]/u, "no emojis or symbol glyphs"); + } +}); + +test("shouldRevealKey: both stdin and stdout on a terminal, not in CI, never for JSON", () => { + const tty = { isTTY: true }, pipe = { isTTY: false }; + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: {} }), true); + assert.equal(shouldRevealKey({ stdin: pipe, stdout: tty, env: {} }), false, "piped stdin (scripts, installers) never reveals"); + assert.equal(shouldRevealKey({ stdin: tty, stdout: pipe, env: {} }), false, "piped stdout (logs) never reveals"); + assert.equal(shouldRevealKey({ stdin: tty, stdout: {}, env: {} }), false); + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: {}, json: true }), false); + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: { CI: "true" } }), false, "CI runners never reveal"); + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: { CI: "1" } }), false); + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: { CI: "false" } }), true); + assert.equal(shouldRevealKey({ stdin: tty, stdout: tty, env: { CI: "0" } }), true); +}); + +test("formatter: Responses route follows the config switch, a non-loopback host is shown with a warning", () => { + const noResponses = connectionDetailsLines({ port: 8457, key: KEY, responsesEnabled: false }).join("\n"); + assert.doesNotMatch(noResponses, /POST \/responses/); + assert.match(noResponses, /POST \/chat\/completions, GET \/models/); + const exposed = connectionDetailsLines({ port: 8457, key: KEY, host: "0.0.0.0" }).join("\n"); + assert.match(exposed, /http:\/\/0\.0\.0\.0:8457\/v1/, "the real listener host is shown, not a loopback guess"); + assert.match(exposed, /WARNING: server.host is 0\.0\.0\.0/); + for (const host of ["127.0.0.1", "127.1.2.3", "localhost", "::1"]) assert.equal(isLoopbackHost(host), true, host); + for (const host of ["0.0.0.0", "192.168.1.5", "example.org"]) assert.equal(isLoopbackHost(host), false, host); + const localhost = connectionDetailsLines({ port: 8457, key: KEY, host: "localhost" }).join("\n"); + assert.match(localhost, /http:\/\/127\.0\.0\.1:8457\/v1/, "loopback aliases are printed as 127.0.0.1"); + assert.doesNotMatch(localhost, /WARNING/); +}); + +test("configuredServer reads host and the Responses switch, with template defaults when absent", (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-conn-srv-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const cfg = join(dir, "config.yaml"); + writeFileSync(cfg, "server:\n host: \"0.0.0.0\"\n port: 8457\nresponses:\n enabled: false\n maxRetries: 2\n"); + assert.deepEqual(configuredServer(cfg), { host: "0.0.0.0", responsesEnabled: false }); + writeFileSync(cfg, "server:\n port: 8457\n host: 127.0.0.1\nresponses:\n enabled: true\n"); + assert.deepEqual(configuredServer(cfg), { host: "127.0.0.1", responsesEnabled: true }); + writeFileSync(cfg, "server:\n port: 8457\n"); + assert.deepEqual(configuredServer(cfg), { host: "127.0.0.1", responsesEnabled: true }); + assert.deepEqual(configuredServer(join(dir, "missing.yaml")), { host: "127.0.0.1", responsesEnabled: true }); +}); + +test("configuredModelIds reads the config list and falls back to the registry snapshot", (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-conn-cfg-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const cfg = join(dir, "config.yaml"); + writeFileSync(cfg, "server:\n port: 1\nmodels:\n - glm-5.3\n - \"glm-5.3-flash\"\n - glm-4.7\nidentity:\n appVersion: \"3.11.2\"\n"); + assert.deepEqual(configuredModelIds(cfg), ["glm-5.3", "glm-5.3-flash", "glm-4.7"]); + writeFileSync(cfg, "server:\n port: 1\n"); + assert.deepEqual(configuredModelIds(cfg), DEFAULT_MODEL_IDS); + assert.deepEqual(configuredModelIds(join(dir, "missing.yaml")), DEFAULT_MODEL_IDS); + assert.deepEqual(configuredModelIds(undefined), DEFAULT_MODEL_IDS); +}); + +// ------------------------------------------------------------- manager +let server = null; +afterEach(() => { if (server) { server.closeAllConnections?.(); server.close(); server = null; } }); + +function kitRoot(port) { + const root = mkdtempSync(join(tmpdir(), "kit-conn-mgr-")); + mkdirSync(join(root, "proxy")); mkdirSync(join(root, "logs")); + writeFileSync(join(root, "proxy", "config.yaml"), `server:\n host: 127.0.0.1\n port: ${port}\nmodels:\n - glm-5.3\n - glm-5.3-flash\n`); + writeFileSync(join(root, ".proxykey"), KEY + "\n"); + return root; +} +async function mockProxy({ mode = "ours", models = [{ id: "mock-a" }, { id: "mock-b" }] } = {}) { + const srv = http.createServer((req, res) => { + if (mode === "wrong-key" || req.headers.authorization !== `Bearer ${KEY}`) { res.writeHead(401).end(); return; } + if (req.url === "/health") { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ status: "ok", provider: "zai" })); return; } + if (req.url === "/v1/models") { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ object: "list", data: models })); return; } + if (req.url === "/quota") { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ provider: "zai", balances: [] })); return; } + res.writeHead(404).end(); + }); + await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + return srv; +} +async function capture(fn) { + const lines = []; + const original = console.log; + console.log = (...args) => lines.push(args.join(" ")); + try { return { code: await fn(), text: lines.join("\n") }; } finally { console.log = original; } +} + +test("status on the own running proxy prints verified details with live model ids and never the key (no terminal)", async (t) => { + server = await mockProxy(); + const root = kitRoot(server.address().port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const m = createManager({ root, home: root }); + const keyFile = join(root, ".proxykey"); + const before = { text: readFileSync(keyFile, "utf8"), mtime: statSync(keyFile).mtimeMs }; + const first = await capture(() => m.status()); + assert.equal(first.code, 0); + assert.match(first.text, /health:\s+ours/); + assert.match(first.text, /running and verified/); + assert.match(first.text, new RegExp(`http://127\\.0\\.0\\.1:${server.address().port}/v1`)); + assert.match(first.text, /Model IDs:\s+mock-a, mock-b/, "ids come from the running instance"); + assert.ok(!first.text.includes(KEY), "key redacted outside a terminal"); + assert.ok(first.text.includes(REDACTED_KEY)); + const second = await capture(() => m.status()); + assert.equal(second.code, 0); + assert.deepEqual({ text: readFileSync(keyFile, "utf8"), mtime: statSync(keyFile).mtimeMs }, before, "status never creates or rotates the key"); +}); + +test("status on a stopped proxy labels configured values and never claims a running proxy", async (t) => { + const probe = await mockProxy(); const port = probe.address().port; probe.close(); + const root = kitRoot(port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const r = await capture(() => createManager({ root, home: root }).status()); + assert.equal(r.code, 1); + assert.match(r.text, /health:\s+down/); + assert.match(r.text, /from configuration; proxy not verified running/); + assert.doesNotMatch(r.text, /running and verified/); + assert.match(r.text, /Model IDs:\s+glm-5\.3, glm-5\.3-flash/, "configured list when nothing runs"); + assert.ok(!r.text.includes(KEY)); +}); + +test("status withholds details when the port is answered by a foreign service", async (t) => { + server = await mockProxy({ mode: "wrong-key" }); + const root = kitRoot(server.address().port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const r = await capture(() => createManager({ root, home: root }).status()); + assert.equal(r.code, 1); + assert.match(r.text, /health:\s+foreign/); + assert.match(r.text, /connection details withheld/); + assert.doesNotMatch(r.text, /base URL/); +}); + +test("start on an already running own proxy exits 0 and the details are re-verified before printing", async (t) => { + server = await mockProxy(); + const root = kitRoot(server.address().port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const m = createManager({ root, home: root }); + const started = await capture(() => m.start()); + assert.equal(started.code, 0, started.text); + assert.match(started.text, /already running/); + const details = await capture(async () => { await m.printConnectionDetails("running"); return 0; }); + assert.match(details.text, /running and verified/); + assert.match(details.text, /mock-a, mock-b/); + server.closeAllConnections(); server.close(); server = null; + const gone = await capture(async () => { await m.printConnectionDetails("running"); return 0; }); + assert.match(gone.text, /proxy not verified running/, "a proxy that stopped meanwhile is not shown as verified"); + assert.doesNotMatch(gone.text, /running and verified/); +}); diff --git a/tests/harness-consent.test.mjs b/tests/harness-consent.test.mjs new file mode 100644 index 0000000..d5c216d --- /dev/null +++ b/tests/harness-consent.test.mjs @@ -0,0 +1,603 @@ +// Per-harness consent (setup asks one y/n question per detected harness): +// prompt mechanics, explicit selections, stored decisions (one file per +// harness), refresh of kit-owned integrations, repair gating, per-harness +// savepoints and the CLI end to end against a disposable fixture kit + fake +// home (no real proxy, no real harness). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile, spawn, spawnSync } from "node:child_process"; +import { + ABORTED, HARNESS_CHOICES_DIR, HARNESS_SELECTION_ENV, NO_CONSENT_HINT, askYesNo, consentStatus, consentedForRepair, + createPrompter, decideHarness, harnessQuestion, readHarnessChoices, recordHarnessChoice, resolveHarnessSelection, +} from "../cli/harness-consent.mjs"; +import { ACCOUNT_ROTATOR_QUESTION, askAccountRotator } from "../cli/account-setup.mjs"; +import { beginTransaction, rollbackTransaction } from "../lib/transaction.mjs"; +import { commitFile } from "../lib/edit.mjs"; + +const KIT = join(import.meta.dirname, ".."); +const IDS = ["omp", "pi", "claude-code", "codex", "opencode", "cline", "kilo-code", "aider", "continue", "goose"]; + +function ttyStreams() { + const input = new PassThrough(), output = new PassThrough(); + input.isTTY = output.isTTY = true; + let transcript = ""; + output.on("data", (chunk) => { transcript += chunk; }); + return { input, output, text: () => transcript, done: () => { input.destroy(); output.destroy(); } }; +} + +// ------------------------------------------------------------- prompter +test("createPrompter: sequential questions on one terminal, typed-ahead answers stay in order, invalid answers repeat", async () => { + const s = ttyStreams(); + const prompter = createPrompter({ input: s.input, output: s.output, env: {} }); + assert.equal(prompter.interactive, true); + const first = prompter.ask(harnessQuestion("OMP / Oh My Pi")); + s.input.write("\nmaybe\ny\nN\nn\n"); // one answer for each question, typed ahead of the prompts + assert.equal(await first, true); + assert.equal(await prompter.ask(harnessQuestion("pi")), false, "typed-ahead line answers the next question"); + assert.equal(await prompter.ask(ACCOUNT_ROTATOR_QUESTION), false); + prompter.close(); + const text = s.text(); + assert.ok(text.includes("Configure ZCode as a provider with its supported models in OMP / Oh My Pi? [y/n]")); + assert.ok(text.includes("Configure ZCode as a provider with its supported models in pi? [y/n]")); + assert.ok(text.includes(`${ACCOUNT_ROTATOR_QUESTION} [y/n]`), "rotator question stays independent"); + assert.ok(text.includes("Please answer y or n")); + s.done(); +}); + +test("createPrompter / askYesNo never consent without a terminal, in CI, or on closed input", async () => { + const plain = { input: new PassThrough(), output: new PassThrough() }; + assert.equal(createPrompter({ ...plain, env: {} }).interactive, false); + assert.equal(await askYesNo("q?", { ...plain, env: {} }), undefined); + const ci = ttyStreams(); + assert.equal(await askYesNo("q?", { input: ci.input, output: ci.output, env: { CI: "true" } }), undefined); + assert.equal(createPrompter({ input: ci.input, output: ci.output, env: { CI: "0" } }).interactive, true, "CI=0 is not CI"); + ci.done(); + const eof = ttyStreams(); + const pending = askYesNo("q?", { input: eof.input, output: eof.output, env: {} }); + eof.input.end(); + assert.equal(await pending, undefined, "EOF is not consent"); + eof.output.destroy(); + const rotator = ttyStreams(); + const answer = askAccountRotator({ input: rotator.input, output: rotator.output, env: {} }); + rotator.input.write("y\n"); + assert.equal(await answer, true, "the rotator question uses the same y/n mechanics"); + rotator.done(); +}); + +test("createPrompter: Ctrl-C rejects the pending question with ABORTED and every later question", async () => { + const s = ttyStreams(); + const prompter = createPrompter({ input: s.input, output: s.output, env: {} }); + const pending = prompter.ask("first?"); + s.input.write("\x03"); // ETX: readline emits SIGINT on a terminal + await assert.rejects(pending, (err) => err.code === ABORTED); + assert.equal(prompter.aborted, true); + await assert.rejects(prompter.ask("second?"), (err) => err.code === ABORTED, "no question is asked after an abort"); + prompter.close(); + s.done(); +}); + +// ----------------------------------------------------- explicit selection +test("resolveHarnessSelection: auto is detection, lists are explicit, flag beats env, unknown ids fail", () => { + assert.equal(resolveHarnessSelection(undefined, undefined, IDS), undefined); + assert.equal(resolveHarnessSelection("auto", undefined, IDS), undefined); + assert.deepEqual(resolveHarnessSelection("omp, codex,omp", undefined, IDS), { ids: ["omp", "codex"], none: false, source: "flag" }); + assert.deepEqual(resolveHarnessSelection(undefined, "pi", IDS), { ids: ["pi"], none: false, source: "env" }); + assert.deepEqual(resolveHarnessSelection("omp", "pi", IDS), { ids: ["omp"], none: false, source: "flag" }); + assert.deepEqual(resolveHarnessSelection("auto", "pi", IDS), { ids: ["pi"], none: false, source: "env" }); + assert.deepEqual(resolveHarnessSelection("none", undefined, IDS), { ids: [], none: true, source: "flag" }); + assert.deepEqual(resolveHarnessSelection(undefined, "none", IDS), { ids: [], none: true, source: "env" }); + assert.equal(resolveHarnessSelection(undefined, "", IDS), undefined, "an empty variable is auto"); + assert.throws(() => resolveHarnessSelection("nonsense", undefined, IDS), /unknown harness "nonsense" in --harness/); + assert.throws(() => resolveHarnessSelection(undefined, "omp,bogus", IDS), new RegExp(`unknown harness "bogus" in ${HARNESS_SELECTION_ENV}`)); +}); + +// ------------------------------------------------------------ decisions +test("decideHarness: explicit lists win, stored decisions stand, answers decide, no answer never consents", async () => { + const yes = async () => true, no = async () => false, none = async () => undefined; + const base = { id: "omp", label: "OMP / Oh My Pi", detected: true, stored: undefined, owned: false, explicit: undefined }; + const y = await decideHarness({ ...base, ask: yes }); + assert.deepEqual([y.action, y.source, y.record, y.consent], ["configure", "interactive", true, true]); + const n = await decideHarness({ ...base, ask: no }); + assert.deepEqual([n.action, n.source, n.reason, n.record, n.consent], ["skip", "interactive", "answered n", true, false]); + const undecided = await decideHarness({ ...base, ask: none }); + assert.deepEqual([undecided.action, undecided.record, undecided.reason], ["skip", false, NO_CONSENT_HINT]); + const headless = await decideHarness({ ...base, ask: null }); + assert.deepEqual([headless.action, headless.reason, headless.consent], ["skip", NO_CONSENT_HINT, false]); + assert.equal((await decideHarness({ ...base, detected: false, ask: yes })).action, "ignore", "undetected harnesses are never asked"); + let asked = 0; + const count = async () => { asked++; return false; }; + const stored = await decideHarness({ ...base, stored: "configured", ask: count }); + assert.deepEqual([stored.action, stored.source, stored.record, stored.consent, asked], ["configure", "stored", false, true, 0], "stored consent is not re-asked"); + const skippedStored = await decideHarness({ ...base, stored: "skipped", ask: async () => { asked++; return true; } }); + assert.deepEqual([skippedStored.action, skippedStored.source, asked], ["skip", "stored", 0], "a stored n is respected even on a terminal"); + assert.match(skippedStored.reason, /previously skipped \(change with: zcode-kit integrate omp, setup --harness omp, or setup --reask\)/); + const reaskYes = await decideHarness({ ...base, stored: "skipped", reask: true, ask: yes }); + assert.deepEqual([reaskYes.action, reaskYes.source, reaskYes.record], ["configure", "interactive", true], "--reask asks a skipped harness again"); + const reaskNo = await decideHarness({ ...base, stored: "configured", reask: true, ask: no }); + assert.deepEqual([reaskNo.action, reaskNo.source, reaskNo.record], ["skip", "interactive", true], "--reask may withdraw stored consent"); + const reaskHeadless = await decideHarness({ ...base, stored: "configured", reask: true, ask: null }); + assert.deepEqual([reaskHeadless.action, reaskHeadless.source, reaskHeadless.record], ["configure", "stored", false], "--reask without a terminal keeps stored decisions"); + const reaskHeadlessN = await decideHarness({ ...base, stored: "skipped", reask: true, ask: null }); + assert.deepEqual([reaskHeadlessN.action, reaskHeadlessN.source], ["skip", "stored"]); + const flag = { ids: ["pi"], none: false, source: "flag" }; + assert.equal((await decideHarness({ ...base, explicit: flag, ask: yes })).action, "ignore"); + const selected = await decideHarness({ ...base, id: "pi", explicit: flag, detected: false, ask: yes }); + assert.deepEqual(selected, { action: "configure", source: "flag", reason: "selected via --harness", record: true, consent: true }); + const selectedOverSkip = await decideHarness({ ...base, id: "pi", stored: "skipped", explicit: flag, ask: null }); + assert.equal(selectedOverSkip.action, "configure", "an explicit selection overrides a stored n"); + const noneSel = { ids: [], none: true, source: "env" }; + assert.deepEqual(await decideHarness({ ...base, explicit: noneSel, ask: yes }), { action: "skip", source: "env", reason: `${HARNESS_SELECTION_ENV}=none`, record: true, consent: false }); + assert.equal((await decideHarness({ ...base, detected: false, explicit: noneSel, ask: yes })).action, "ignore"); +}); + +test("decideHarness: a kit-owned integration is refreshed without consent, asked about on a terminal, and never wins over a stored n", async () => { + const base = { id: "omp", label: "OMP / Oh My Pi", detected: true, stored: undefined, owned: true, explicit: undefined }; + const headless = await decideHarness({ ...base, ask: null }); + assert.deepEqual([headless.action, headless.source, headless.record, headless.consent], ["refresh", "existing", false, false]); + assert.match(headless.reason, /consent not recorded/); + let asked = 0; + const y = await decideHarness({ ...base, ask: async () => { asked++; return true; } }); + assert.deepEqual([y.action, y.source, y.record, y.consent, asked], ["configure", "interactive", true, true, 1], "an owned integration is still asked about"); + assert.match(y.note, /existing kit integration found/); + const n = await decideHarness({ ...base, ask: async () => false }); + assert.deepEqual([n.action, n.record], ["skip", true], "n on an owned integration records the decision and leaves the files alone"); + const eof = await decideHarness({ ...base, ask: async () => undefined }); + assert.deepEqual([eof.action, eof.source, eof.record, eof.consent], ["refresh", "existing", false, false], "no answer: refreshed, nothing recorded"); + const storedN = await decideHarness({ ...base, stored: "skipped", ask: null }); + assert.equal(storedN.action, "skip", "a stored n wins over an owned artifact"); + await assert.rejects(decideHarness({ ...base, ask: async () => { const e = new Error("x"); e.code = ABORTED; throw e; } }), (err) => err.code === ABORTED, "Ctrl-C propagates"); +}); + +test("decideHarness: an unreadable decision file leaves the harness undecided and is never overwritten", async () => { + const base = { id: "pi", label: "pi", detected: true, stored: undefined, unreadable: true, owned: true, explicit: undefined }; + const headless = await decideHarness({ ...base, ask: null }); + assert.deepEqual([headless.action, headless.record, headless.consent], ["skip", false, false], "no refresh shortcut on an unreadable decision"); + assert.match(headless.reason, /decision file unreadable/); + const y = await decideHarness({ ...base, ask: async () => true }); + assert.deepEqual([y.action, y.record, y.consent], ["configure", false, true], "a y applies this run but is not recorded over the broken file"); + const n = await decideHarness({ ...base, ask: async () => false }); + assert.deepEqual([n.action, n.record], ["skip", false]); + const explicit = await decideHarness({ ...base, explicit: { ids: ["pi"], none: false, source: "flag" }, ask: null }); + assert.deepEqual([explicit.action, explicit.record], ["configure", true], "explicit selection still applies (recordHarnessChoice refuses the write)"); +}); + +// --------------------------------------------------------- choices store +function txFixture(t, prefix) { + const root = mkdtempSync(join(tmpdir(), prefix)); + t.after(() => rmSync(root, { recursive: true, force: true })); + const generated = join(root, "generated"); + const backupDir = join(root, "backups"); + mkdirSync(generated, { recursive: true }); mkdirSync(backupDir, { recursive: true }); + return { root, generated, backupDir, ctx: { generated, backupDir, dryRun: false, tx: null } }; +} +const choiceFile = (generated, id) => join(generated, HARNESS_CHOICES_DIR, `${id}.json`); + +test("harness choices: one file per harness, malformed files fail closed and are never overwritten, writes go through the transaction, dry-run writes nothing", (t) => { + const f = txFixture(t, "kit-choices-"); + assert.deepEqual(readHarnessChoices(f.ctx), { harnesses: {}, unreadable: [], errors: [] }); + const dir = join(f.generated, HARNESS_CHOICES_DIR); + mkdirSync(dir); + writeFileSync(join(dir, "omp.json"), JSON.stringify({ schema: 1, harness: "omp", decision: "configured", source: "flag", decidedAt: "2026-01-01T00:00:00.000Z" })); + writeFileSync(join(dir, "pi.json"), "{ not json"); + writeFileSync(join(dir, "codex.json"), JSON.stringify({ schema: 1, decision: "maybe" })); + writeFileSync(join(dir, "notes.txt"), "ignored"); + const loaded = readHarnessChoices(f.ctx); + assert.deepEqual(loaded.harnesses, { omp: { decision: "configured", source: "flag", decidedAt: "2026-01-01T00:00:00.000Z" } }); + assert.deepEqual(loaded.unreadable.sort(), ["codex", "pi"], "malformed and unknown decisions count as unreadable, never as consent"); + assert.equal(loaded.errors.length, 2); + assert.ok(loaded.errors.every((e) => /fix or remove the file/.test(e))); + + const tx = beginTransaction(f.backupDir, "test"); + f.ctx.tx = tx; + recordHarnessChoice(f.ctx, tx, loaded, "goose", "skipped", "interactive", () => "2026-02-02T00:00:00.000Z"); + const written = JSON.parse(readFileSync(choiceFile(f.generated, "goose"), "utf8")); + assert.deepEqual(written, { schema: 1, harness: "goose", decision: "skipped", source: "interactive", decidedAt: "2026-02-02T00:00:00.000Z" }); + assert.equal(loaded.harnesses.goose.decision, "skipped", "the in-memory view follows the write"); + assert.equal(tx.ops.length, 1, "recorded in the transaction"); + assert.equal(tx.ops[0].kind, "create"); + const firstHash = tx.ops[0].expectedHash; + recordHarnessChoice(f.ctx, tx, loaded, "goose", "skipped", "interactive", () => "2026-03-03T00:00:00.000Z"); + assert.equal(readFileSync(choiceFile(f.generated, "goose"), "utf8"), JSON.stringify(written, null, 2) + "\n", "an unchanged decision is not rewritten"); + assert.equal(tx.ops[0].expectedHash, firstHash); + recordHarnessChoice(f.ctx, tx, loaded, "goose", "configured", "flag", () => "2026-03-03T00:00:00.000Z"); + assert.equal(JSON.parse(readFileSync(choiceFile(f.generated, "goose"), "utf8")).decision, "configured", "a changed decision is rewritten"); + assert.equal(tx.ops.length, 1, "the same file stays one journalled op"); + assert.deepEqual(tx.ops[0].expectedHashes, [firstHash], "both write hashes stay known to the rollback"); + assert.notEqual(tx.ops[0].expectedHash, firstHash); + + recordHarnessChoice(f.ctx, tx, loaded, "pi", "configured", "flag"); + assert.equal(readFileSync(join(dir, "pi.json"), "utf8"), "{ not json", "an unreadable decision is never overwritten"); + assert.equal(loaded.harnesses.pi, undefined); + + const before = readdirSync(dir).sort(); + recordHarnessChoice({ ...f.ctx, dryRun: true }, tx, readHarnessChoices(f.ctx), "aider", "configured", "flag"); + assert.deepEqual(readdirSync(dir).sort(), before, "dry-run never writes"); + + // Rolling the transaction back removes the decisions it recorded. + const id = tx.finish(); + const r = rollbackTransaction(f.backupDir, id); + assert.ok(r.complete, JSON.stringify(r)); + assert.equal(existsSync(choiceFile(f.generated, "goose")), false, "rollback removes the recorded decision"); + assert.equal(existsSync(join(dir, "omp.json")), true, "decisions from earlier runs stay"); +}); + +test("an unreadable choices directory marks every harness unreadable", (t) => { + const f = txFixture(t, "kit-choices-dir-"); + writeFileSync(join(f.generated, HARNESS_CHOICES_DIR), "a file where the directory should be"); + const loaded = readHarnessChoices(f.ctx); + assert.deepEqual(loaded.harnesses, {}); + assert.deepEqual(loaded.unreadable, ["*"]); + assert.equal(loaded.errors.length, 1); + const tx = beginTransaction(f.backupDir, "test"); + recordHarnessChoice(f.ctx, tx, loaded, "omp", "configured", "flag"); + assert.equal(tx.ops.length, 0, "nothing is written over an unreadable store"); + assert.deepEqual(consentedForRepair(f.ctx, ["omp"], { omp: true }, { omp: { owned: () => true } }, loaded), [], "no repair while the store is unreadable"); + assert.match(consentStatus(f.ctx, "omp", { owned: () => true }, true, loaded).detail, /decision file unreadable/); +}); + +test("consentedForRepair and consentStatus keep repairs and checks inside stored consent or kit-owned integrations", (t) => { + const f = txFixture(t, "kit-repair-"); + const dir = join(f.generated, HARNESS_CHOICES_DIR); + mkdirSync(dir); + const write = (id, decision) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 1, harness: id, decision, source: "interactive", decidedAt: "" })); + write("omp", "configured"); write("pi", "skipped"); write("cline", "configured"); + writeFileSync(join(dir, "kilo-code.json"), "broken"); + const adapters = { + omp: { owned: () => false }, + pi: { owned: () => true }, + codex: { owned: () => true }, + aider: { owned: () => false }, + goose: { owned: () => { throw new Error("unreadable"); } }, + cline: { owned: () => false }, + "kilo-code": { owned: () => true }, + }; + const detected = { omp: true, pi: true, codex: true, aider: true, goose: true, cline: false, "kilo-code": true }; + const ids = ["omp", "pi", "codex", "aider", "goose", "cline", "kilo-code"]; + assert.deepEqual(consentedForRepair(f.ctx, ids, detected, adapters), ["omp", "codex"], + "stored y or an owned integration; never a stored n, an undetected, an unowned or an unreadable harness"); + assert.deepEqual(consentedForRepair(f.ctx, ["omp"], { omp: false }, adapters), [], "undetected harnesses are never repaired"); + assert.deepEqual(consentStatus(f.ctx, "omp", adapters.omp, true), { verify: true }); + assert.deepEqual(consentStatus(f.ctx, "codex", adapters.codex, true), { verify: true }, "owned integrations are verified"); + const declined = consentStatus(f.ctx, "pi", adapters.pi, true); + assert.equal(declined.verify, false); assert.match(declined.detail, /your choice.*zcode-kit integrate pi/); + const undecided = consentStatus(f.ctx, "aider", adapters.aider, true); + assert.equal(undecided.verify, false); assert.match(undecided.detail, /no consent yet/); + assert.match(consentStatus(f.ctx, "goose", adapters.goose, false).detail, /not detected/); + assert.match(consentStatus(f.ctx, "kilo-code", adapters["kilo-code"], true).detail, /decision file unreadable/); +}); + +// ------------------------------------------------------- savepoints +test("transaction savepoint/restoreSince undoes only the ops of one failed harness and keeps foreign edits", (t) => { + const f = txFixture(t, "kit-savepoint-"); + const ctx = { generated: f.generated, backupDir: f.backupDir, dryRun: false }; + const tx = beginTransaction(f.backupDir, "test"); + ctx.tx = tx; + const kept = join(f.root, "kept.txt"); + writeFileSync(kept, "before\n"); + commitFile(ctx, tx, kept, "first harness\n"); + const savepoint = tx.savepoint(); + assert.equal(savepoint, 1); + const modified = join(f.root, "modified.txt"); + writeFileSync(modified, "original\n"); + const created = join(f.root, "created.txt"); + const foreign = join(f.root, "foreign.txt"); + writeFileSync(foreign, "original\n"); + commitFile(ctx, tx, modified, "kit write\n"); + commitFile(ctx, tx, created, "kit created\n"); + commitFile(ctx, tx, foreign, "kit write\n"); + writeFileSync(foreign, "changed by someone else\n"); + tx.external("registered elsewhere", "undo elsewhere"); + const result = tx.restoreSince(savepoint); + assert.deepEqual(result, { restored: [modified], removed: [created], conflicts: [foreign] }); + assert.equal(readFileSync(modified, "utf8"), "original\n", "modified file restored from its backup"); + assert.equal(existsSync(created), false, "created file removed"); + assert.equal(readFileSync(foreign, "utf8"), "changed by someone else\n", "a file changed meanwhile is left alone"); + assert.equal(readFileSync(kept, "utf8"), "first harness\n", "ops before the savepoint stay applied"); + assert.deepEqual(tx.ops.map((op) => op.kind), ["modify", "modify", "external"], "conflicts and externals stay journalled"); + const id = tx.finish(); + const rolled = rollbackTransaction(f.backupDir, id); + assert.equal(readFileSync(kept, "utf8"), "before\n", "the finished transaction still rolls back the kept op"); + assert.equal(rolled.conflicts.length, 1); assert.match(rolled.conflicts[0], /changed after the transaction/); + assert.equal(rolled.external.length, 1, "the external op still asks for its manual undo"); + assert.equal(rolled.complete, false); +}); + +// ------------------------------------------------------------ CLI e2e +function fixture(t, { detect = ["omp", "pi"], mcpDist = true } = {}) { + const root = mkdtempSync(join(tmpdir(), "kit-consent-e2e-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const member of ["cli", "lib"]) cpSync(join(KIT, member), join(root, member), { recursive: true }); + mkdirSync(join(root, "proxy")); mkdirSync(join(root, "logs")); mkdirSync(join(root, "zcode-proxy-src")); + cpSync(join(KIT, "proxy", "config.example.yaml"), join(root, "proxy", "config.example.yaml")); + cpSync(join(KIT, "proxy", "zcode-proxy-manager.mjs"), join(root, "proxy", "zcode-proxy-manager.mjs")); + cpSync(join(KIT, "proxy", "zcode-proxy-autostart.ts"), join(root, "proxy", "zcode-proxy-autostart.ts")); + // The OMP adapter validates YAML with the proxy's `yaml` dependency. + cpSync(join(KIT, "zcode-proxy-src", "package.json"), join(root, "zcode-proxy-src", "package.json")); + symlinkSync(join(KIT, "zcode-proxy-src", "node_modules"), join(root, "zcode-proxy-src", "node_modules"), process.platform === "win32" ? "junction" : "dir"); + if (mcpDist) { + mkdirSync(join(root, "mcp", "zcode-harness-mcp", "dist"), { recursive: true }); + writeFileSync(join(root, "mcp", "zcode-harness-mcp", "dist", "index.js"), "// fixture bridge\n"); + } + const home = join(root, "home"); + mkdirSync(home); + if (detect.includes("omp")) { + mkdirSync(join(home, ".omp", "agent"), { recursive: true }); + writeFileSync(join(home, ".omp", "agent", "models.yml"), "# user models\nproviders:\n openai:\n name: OpenAI\n"); + writeFileSync(join(home, ".omp", "agent", "config.yml"), "extensions: []\n"); + } + if (detect.includes("pi")) { + mkdirSync(join(home, ".pi", "agent"), { recursive: true }); + writeFileSync(join(home, ".pi", "agent", "models.json"), JSON.stringify({ providers: { ollama: { baseUrl: "http://localhost:11434/v1", api: "openai-completions", models: [{ id: "llama" }] } } }, null, 2) + "\n"); + } + const env = { + ...process.env, HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), LOCALAPPDATA: join(home, "AppData", "Local"), + XDG_CONFIG_HOME: join(home, ".config"), ZCODE_KIT_SKIP_DEPS: "1", ZCODE_PROXY_CREDENTIALS_PATH: join(home, "credentials.json"), + PATH: process.platform === "win32" ? "C:\\Windows\\System32" : "/usr/bin:/bin", + }; + delete env[HARNESS_SELECTION_ENV]; delete env.ZCODE_KIT_STATE_DIR; delete env.ZCODE_KIT_ACCOUNT_ROTATOR; delete env.CI; + const choicesDir = join(root, "generated", HARNESS_CHOICES_DIR); + return { + root, home, env, choicesDir, + omp: join(home, ".omp", "agent", "models.yml"), pi: join(home, ".pi", "agent", "models.json"), + mcp: join(home, ".omp", "agent", "mcp.json"), + choice: (id) => join(choicesDir, `${id}.json`), + key: () => readFileSync(join(root, ".proxykey"), "utf8").trim(), + }; +} +function run(f, args, extraEnv = {}) { + return new Promise((resolve) => { + execFile(process.execPath, [join(f.root, "cli", "zcode-kit.mjs"), ...args], { env: { ...f.env, ...extraEnv }, encoding: "utf8", timeout: 60000 }, + (err, stdout, stderr) => resolve({ code: err ? (err.code ?? -1) : 0, stdout, stderr, text: `${stdout}\n${stderr}` })); + }); +} +/** { id: "decision/source" } for every readable decision file, null when none exist. */ +function choicesOf(f) { + if (!existsSync(f.choicesDir)) return null; + const out = {}; + for (const name of readdirSync(f.choicesDir).sort()) { + try { const j = JSON.parse(readFileSync(join(f.choicesDir, name), "utf8")); out[name.slice(0, -5)] = `${j.decision}/${j.source}`; } catch { out[name.slice(0, -5)] = "unreadable"; } + } + return Object.keys(out).length ? out : null; +} +const writeChoice = (f, id, decision, source = "interactive") => { + mkdirSync(f.choicesDir, { recursive: true }); + writeFileSync(f.choice(id), JSON.stringify({ schema: 1, harness: id, decision, source, decidedAt: "" })); +}; + +test("setup without a terminal skips every undecided harness, writes nothing, exits 0 and redacts the key", async (t) => { + const f = fixture(t); + const before = [readFileSync(f.omp, "utf8"), readFileSync(f.pi, "utf8")]; + const r = await run(f, ["setup"]); + assert.equal(r.code, 0, r.text); + assert.match(r.stdout, /No interactive terminal: harnesses without a saved decision are skipped/); + assert.match(r.stdout, /\[SKIP\] OMP \/ Oh My Pi — no interactive consent/); + assert.match(r.stdout, /\[SKIP\] pi — no interactive consent/); + assert.match(r.stdout, /Assistants: 0 configured, 2 skipped, 0 failed/); + assert.match(r.stdout, /Undecided harnesses can be configured later/); + assert.match(r.stdout, /Account Rotator setting unchanged \(no interactive answer\)/, "rotator question stays independent"); + assert.doesNotMatch(r.stdout, /Configure ZCode as a provider/, "no question is printed without a terminal"); + assert.deepEqual([readFileSync(f.omp, "utf8"), readFileSync(f.pi, "utf8")], before, "harness configs untouched"); + assert.equal(existsSync(f.mcp), false, "no MCP registration without consent"); + assert.equal(choicesOf(f), null, "no answer, no stored decision"); + assert.match(r.stdout, /OpenAI-compatible base URL:\s+http:\/\/127\.0\.0\.1:8457\/v1/); + assert.match(r.stdout, /Anthropic-compatible base URL: http:\/\/127\.0\.0\.1:8457 /); + assert.match(r.stdout, /proxy not verified running/); + assert.ok(!r.text.includes(f.key()), "the local proxy key never appears in non-terminal output"); +}); + +test("explicit --harness selection configures only the named harness, records it, and later auto runs keep it without re-asking", async (t) => { + const f = fixture(t); + const piBefore = readFileSync(f.pi, "utf8"); + const r1 = await run(f, ["setup", "--harness", "omp"]); + assert.equal(r1.code, 0, r1.text); + assert.match(r1.stdout, /\[OK\]\s+OMP \/ Oh My Pi/); + assert.match(r1.stdout, /Assistants: 1 configured, 0 skipped, 0 failed/); + assert.doesNotMatch(r1.stdout, /No interactive terminal/, "an explicit selection needs no terminal"); + assert.match(readFileSync(f.omp, "utf8"), /# >>> zcode-kit \(managed block\)/); + assert.equal(readFileSync(f.pi, "utf8"), piBefore, "unselected harness untouched"); + assert.ok(existsSync(f.mcp), "MCP registered for the consented harness only"); + assert.deepEqual(choicesOf(f), { omp: "configured/flag" }); + const ompAfter = readFileSync(f.omp, "utf8"); + const r2 = await run(f, ["setup"]); + assert.equal(r2.code, 0, r2.text); + assert.match(r2.stdout, /\[OK\]\s+OMP \/ Oh My Pi\s*$/m, "stored consent is honoured without a refresh note"); + assert.match(r2.stdout, /\[SKIP\] pi — no interactive consent/); + assert.equal(readFileSync(f.omp, "utf8"), ompAfter, "idempotent"); + assert.doesNotMatch(r2.stdout, /transaction \S+ recorded/, "no-op run records no transaction"); + const r3 = await run(f, ["setup"], { [HARNESS_SELECTION_ENV]: "pi" }); + assert.equal(r3.code, 0, r3.text); + assert.match(r3.stdout, /\[OK\]\s+pi/); + assert.ok(JSON.parse(readFileSync(f.pi, "utf8")).providers.zcode, "env selection configures pi"); + assert.deepEqual(choicesOf(f), { omp: "configured/flag", pi: "configured/env" }, "env selection leaves other decisions alone"); + const bad = await run(f, ["setup", "--harness", "nonsense"]); + assert.equal(bad.code, 2, "an unknown harness is an error, not a no-op"); + assert.match(bad.stderr, /unknown harness "nonsense"/); +}); + +test("`none` records skipped decisions that later runs respect; an explicit selection or --reask changes them, n never deletes", async (t) => { + const f = fixture(t); + const r1 = await run(f, ["setup"], { [HARNESS_SELECTION_ENV]: "none" }); + assert.equal(r1.code, 0, r1.text); + assert.match(r1.stdout, new RegExp(`\\[SKIP\\] OMP / Oh My Pi — ${HARNESS_SELECTION_ENV}=none`)); + assert.match(r1.stdout, /Assistants: 0 configured, 2 skipped, 0 failed/); + assert.deepEqual(choicesOf(f), { omp: "skipped/env", pi: "skipped/env" }); + const r2 = await run(f, ["setup"]); + assert.match(r2.stdout, /\[SKIP\] OMP \/ Oh My Pi — previously skipped \(change with: zcode-kit integrate omp, setup --harness omp, or setup --reask\)/); + assert.doesNotMatch(r2.stdout, /Undecided harnesses/, "a stored n is a decision, not an open question"); + assert.doesNotMatch(readFileSync(f.omp, "utf8"), /zcode-kit/); + const r3 = await run(f, ["setup", "--harness", "pi"]); + assert.equal(r3.code, 0, r3.text); + assert.deepEqual(choicesOf(f), { omp: "skipped/env", pi: "configured/flag" }); + const configured = readFileSync(f.pi, "utf8"); + const r4 = await run(f, ["setup", "--reask"]); + assert.equal(r4.code, 0, r4.text); + assert.match(r4.stdout, /\[SKIP\] OMP \/ Oh My Pi — previously skipped/, "--reask without a terminal keeps stored decisions"); + assert.match(r4.stdout, /\[OK\]\s+pi\s*$/m); + assert.deepEqual(choicesOf(f), { omp: "skipped/env", pi: "configured/flag" }, "--reask without an answer changes no decision"); + assert.equal(readFileSync(f.pi, "utf8"), configured, "and deletes nothing"); +}); + +test("a kit-owned integration without a stored decision is refreshed (no consent recorded, no MCP); a stored n never deletes it", async (t) => { + const f = fixture(t); + assert.equal((await run(f, ["setup", "--harness", "omp"])).code, 0); + const configured = readFileSync(f.omp, "utf8"); + rmSync(f.choicesDir, { recursive: true }); + rmSync(f.mcp); + const r = await run(f, ["setup"]); + assert.equal(r.code, 0, r.text); + assert.match(r.stdout, /\[OK\]\s+OMP \/ Oh My Pi — existing kit integration refreshed \(no consent recorded\)/); + assert.equal(choicesOf(f), null, "a refresh never records consent"); + assert.equal(readFileSync(f.omp, "utf8"), configured); + assert.equal(existsSync(f.mcp), false, "MCP registration follows consent, not a refresh"); + writeChoice(f, "omp", "skipped"); + const r2 = await run(f, ["setup"]); + assert.match(r2.stdout, /\[SKIP\] OMP \/ Oh My Pi — previously skipped/); + assert.equal(readFileSync(f.omp, "utf8"), configured, "n leaves the existing block untouched"); +}); + +test("an unreadable decision file blocks the refresh shortcut and is left alone; the run still succeeds", async (t) => { + const f = fixture(t); + assert.equal((await run(f, ["setup", "--harness", "omp"])).code, 0); + writeFileSync(f.choice("omp"), "{ broken"); + const r = await run(f, ["setup"]); + assert.equal(r.code, 0, r.text); + assert.match(r.stdout, /\[SKIP\] OMP \/ Oh My Pi — decision file unreadable — fix or remove it/); + assert.match(r.text, /omp\.json: .*fix or remove the file/); + assert.equal(readFileSync(f.choice("omp"), "utf8"), "{ broken", "never overwritten"); + const r2 = await run(f, ["setup", "--harness", "omp"]); + assert.equal(r2.code, 0, r2.text); + assert.match(r2.stdout, /\[OK\]\s+OMP \/ Oh My Pi/, "an explicit selection still applies"); + assert.equal(readFileSync(f.choice("omp"), "utf8"), "{ broken", "still never overwritten"); +}); + +test("one failing harness does not stop the others: its partial writes are undone, summary distinguishes configured and failed, exit 1", async (t) => { + const f = fixture(t); + writeFileSync(f.pi, "{ this is not json"); + const r = await run(f, ["setup", "--harness", "omp,pi"]); + assert.equal(r.code, 1, r.text); + assert.match(r.stdout, /\[OK\]\s+OMP \/ Oh My Pi/); + assert.match(r.stdout, /\[FAIL\] pi — .*not valid JSON/); + assert.match(r.stdout, /Assistants: 1 configured, 0 skipped, 1 failed/); + assert.match(r.stdout, /failed: pi — /); + assert.match(readFileSync(f.omp, "utf8"), /# >>> zcode-kit \(managed block\)/); + assert.deepEqual(choicesOf(f), { omp: "configured/flag" }, "a failed harness is never recorded as configured"); + assert.equal(readFileSync(f.pi, "utf8"), "{ this is not json", "refused file left byte-identical"); +}); + +test("no detected harness is not an error; integrate records consent; doctor reports declined harnesses as SKIP and --fix repairs only consented ones", async (t) => { + const f = fixture(t, { detect: [] }); + const r = await run(f, ["setup"]); + assert.equal(r.code, 0, r.text); + assert.match(r.stdout, /No supported assistant detected/); + assert.match(r.stdout, /Assistants: 0 configured, 0 skipped, 0 failed/); + const g = fixture(t); + assert.equal((await run(g, ["integrate", "pi"])).code, 0); + assert.deepEqual(choicesOf(g), { pi: "configured/integrate" }); + writeChoice(g, "omp", "skipped"); + const doctor = await run(g, ["doctor", "--json"]); + const checks = JSON.parse(doctor.stdout.slice(doctor.stdout.indexOf("{"))).checks; + const omp = checks.find((c) => c.name === "omp: integration"); + assert.equal(omp?.ok, null, "a declined harness is a SKIP, not a FAIL"); + assert.match(omp.detail, /your choice/); + assert.ok(checks.some((c) => c.name.startsWith("pi: ") && c.ok === true), "the consented harness is verified"); + assert.ok(!checks.some((c) => c.name.startsWith("omp: ") && c.ok === false), `no OMP failure\n${doctor.stdout}`); + const piDoc = JSON.parse(readFileSync(g.pi, "utf8")); + delete piDoc.providers.zcode; // drift: the consented integration is missing again + writeFileSync(g.pi, JSON.stringify(piDoc, null, 2) + "\n"); + const ompBefore = readFileSync(g.omp, "utf8"); + const fix = await run(g, ["doctor", "--fix"]); + assert.ok(JSON.parse(readFileSync(g.pi, "utf8")).providers.zcode, `doctor --fix re-applies the consented harness\n${fix.text}`); + assert.equal(readFileSync(g.omp, "utf8"), ompBefore, "doctor --fix never integrates a skipped harness"); +}); + +test("rolling back a setup removes the decisions it recorded together with the integration", async (t) => { + const f = fixture(t); + const r = await run(f, ["setup", "--harness", "omp"]); + assert.equal(r.code, 0, r.text); + const before = readFileSync(f.omp, "utf8"); + const rb = await run(f, ["rollback"]); + assert.equal(rb.code, 0, rb.text); + assert.doesNotMatch(readFileSync(f.omp, "utf8"), /zcode-kit/, "integration undone"); + assert.notEqual(readFileSync(f.omp, "utf8"), before); + assert.equal(choicesOf(f), null, "the decision recorded by that setup is gone too"); +}); + +// Real terminal proof (Linux `script` allocates a pty): questions are asked in +// order, y configures, n skips, the rotator question follows separately, and +// Ctrl-C stops the questions without touching what was not consented to. +const script = process.platform === "linux" && spawnSync("script", ["--version"], { stdio: "ignore" }).status === 0; +/** + * Run the CLI on a pty. `input` is either a string typed ahead before the + * first question, or [{ after, send }] pairs typed once `after` appeared in + * the output (needed for keys the line discipline would act on itself, such + * as Ctrl-C before readline switched the pty to raw mode). + */ +function runPty(f, args, input, timeout = 90000) { + const cli = join(f.root, "cli", "zcode-kit.mjs"); + return new Promise((resolve) => { + const child = spawn("script", ["-qec", `${process.execPath} ${cli} ${args.join(" ")}`, "/dev/null"], { env: f.env }); + let raw = "", stderr = ""; + const scripted = Array.isArray(input) ? [...input] : null; + const timer = setTimeout(() => child.kill("SIGKILL"), timeout); + child.stdout.on("data", (chunk) => { + raw += chunk; + while (scripted?.length && raw.includes(scripted[0].after)) child.stdin.write(scripted.shift().send); + }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ code: code ?? `signal ${signal}`, out: raw.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""), stderr }); + }); + if (!scripted) { child.stdin.write(input); child.stdin.end(); } + }); +} +test("interactive setup asks per harness and applies the answers", { skip: !script }, async (t) => { + const f = fixture(t); + const r = await runPty(f, ["setup"], "y\nn\nn\n"); + assert.equal(r.code, 0, `${r.out}\n${r.stderr}`); + assert.ok(r.out.indexOf("in OMP / Oh My Pi? [y/n]") < r.out.indexOf("in pi? [y/n]"), "questions in detection order"); + assert.ok(r.out.indexOf("in pi? [y/n]") < r.out.indexOf(`${ACCOUNT_ROTATOR_QUESTION} [y/n]`), "rotator question comes after the harness questions"); + assert.match(r.out, /note: y also registers the kit's MCP bridge "zcode-harness" for OMP \/ Oh My Pi/); + assert.match(r.out, /\[OK\]\s+OMP \/ Oh My Pi/); + assert.match(r.out, /\[SKIP\] pi — answered n/); + assert.match(readFileSync(f.omp, "utf8"), /# >>> zcode-kit \(managed block\)/); + assert.ok(!JSON.parse(readFileSync(f.pi, "utf8")).providers.zcode, "n writes nothing"); + assert.ok(existsSync(f.mcp), "MCP registered after y"); + assert.deepEqual(choicesOf(f), { omp: "configured/interactive", pi: "skipped/interactive" }); + assert.ok(r.out.includes(f.key()), "an interactive terminal shows the copyable key"); + // A second interactive run neither re-asks the decided harnesses nor changes them. + const again = await runPty(f, ["setup"], "n\n"); + assert.equal(again.code, 0, `${again.out}\n${again.stderr}`); + assert.doesNotMatch(again.out, /in OMP \/ Oh My Pi\? \[y\/n\]/, "stored y is not asked again"); + assert.doesNotMatch(again.out, /in pi\? \[y\/n\]/, "stored n is not asked again"); + assert.match(again.out, /\[SKIP\] pi — previously skipped/); + assert.deepEqual(choicesOf(f), { omp: "configured/interactive", pi: "skipped/interactive" }); + // --reask asks both again; the owned OMP integration is announced before its question. + const reask = await runPty(f, ["setup", "--reask"], "n\ny\nn\n"); + assert.equal(reask.code, 0, `${reask.out}\n${reask.stderr}`); + assert.match(reask.out, /note: an existing kit integration for OMP \/ Oh My Pi was found; y keeps it current, n leaves it untouched/); + assert.match(reask.out, /\[SKIP\] OMP \/ Oh My Pi — answered n/); + assert.match(reask.out, /\[OK\]\s+pi/); + assert.match(readFileSync(f.omp, "utf8"), /# >>> zcode-kit \(managed block\)/, "n never deletes an existing integration"); + assert.ok(JSON.parse(readFileSync(f.pi, "utf8")).providers.zcode); + assert.deepEqual(choicesOf(f), { omp: "skipped/interactive", pi: "configured/interactive" }); +}); + +test("Ctrl-C during the questions stops setup: answered harnesses stay, the rest is skipped, exit 130", { skip: !script }, async (t) => { + const f = fixture(t); + const piBefore = readFileSync(f.pi, "utf8"); + const r = await runPty(f, ["setup"], [{ after: "in OMP / Oh My Pi? [y/n]", send: "y\n" }, { after: "in pi? [y/n]", send: "\x03" }]); + assert.equal(r.code, 130, `${r.out}\n${r.stderr}`); + assert.match(r.out, /\[OK\]\s+OMP \/ Oh My Pi/, "the y given before Ctrl-C is applied"); + assert.match(r.out, /\[SKIP\] pi — aborted/); + assert.match(r.out, /Aborted by the user: remaining questions were skipped/); + assert.match(r.out, /\[SKIP\] aborted by the user/, "no connection check after an abort"); + assert.doesNotMatch(r.out, /Account Rotator enabled|Account Rotator disabled/, "the rotator question is not answered by the abort"); + assert.match(readFileSync(f.omp, "utf8"), /# >>> zcode-kit \(managed block\)/); + assert.equal(readFileSync(f.pi, "utf8"), piBefore, "the aborted harness is untouched"); + assert.deepEqual(choicesOf(f), { omp: "configured/interactive" }, "only the given answer is recorded"); +}); diff --git a/tests/setup-regressions.test.mjs b/tests/setup-regressions.test.mjs index 8d89d94..3ca93f0 100644 --- a/tests/setup-regressions.test.mjs +++ b/tests/setup-regressions.test.mjs @@ -59,6 +59,9 @@ function runSetup(home, args = []) { // The suite runs the kit from a source checkout (KIT has .git); tests // intentionally write to the disposable fake home, so opt in. ZCODE_KIT_ALLOW_CHECKOUT: "1", + // No terminal here: consent for the fake OMP is given explicitly, the + // way unattended installs do (a missing TTY never counts as consent). + ZCODE_KIT_HARNESSES: "omp", // Test isolation: hide the machine's real claude/codex/bun from PATH so // detection only sees the fake home (node.exe is spawned by absolute path). PATH: process.platform === "win32" ? "C:\\Windows\\System32" : "/usr/bin:/bin", @@ -194,7 +197,8 @@ test("update from a checkout completes — re-setup implies the checkout opt-in }, encoding: "utf8", }); - assert.match(out, /re-applying integrations for detected harnesses/); + assert.match(out, /refreshing consented integrations/); + assert.match(out, /Connection details unavailable: no proxy manager in this kit layout/, "a stub manager must not abort the re-setup"); assert.match(out, /starting the proxy on the updated code/, "update must restart the proxy after re-setup"); assert.match(out, /stub manager: start/, "the manager must be invoked with the start subcommand"); assert.doesNotMatch(out, /refusing to write user configs from a source checkout/); diff --git a/zcode-proxy-src/README.de.md b/zcode-proxy-src/README.de.md index 18a26f8..6ea584a 100644 --- a/zcode-proxy-src/README.de.md +++ b/zcode-proxy-src/README.de.md @@ -2,8 +2,8 @@ [English (original)](README.md) · **Deutsch** · [Español](README.es.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) Diese Komponente stellt den lokalen Modell-Proxy bereit, der mit dem ZCode -Agent Kit gebündelt wird. Das Kit übernimmt Einrichtung und Integration mit -Assistenten. +Agent Kit gebündelt wird. Das Kit übernimmt die Einrichtung; Integrationen +mit Assistenten werden nur mit deiner Zustimmung konfiguriert. ## Mit dem ZCode Agent Kit verwenden diff --git a/zcode-proxy-src/README.es.md b/zcode-proxy-src/README.es.md index 918f2f5..88ab1f9 100644 --- a/zcode-proxy-src/README.es.md +++ b/zcode-proxy-src/README.es.md @@ -2,7 +2,8 @@ [English (original)](README.md) · [Deutsch](README.de.md) · **Español** · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) Este componente proporciona el proxy local de modelos incluido en ZCode Agent -Kit. El Kit se encarga de configurarlo e integrarlo con los asistentes. +Kit. El Kit se encarga de su configuración; las integraciones con asistentes +solo se configuran con tu consentimiento. ## Uso con ZCode Agent Kit diff --git a/zcode-proxy-src/README.ja.md b/zcode-proxy-src/README.ja.md index 72881f7..d8ed18e 100644 --- a/zcode-proxy-src/README.ja.md +++ b/zcode-proxy-src/README.ja.md @@ -2,8 +2,8 @@ [English (original)](README.md) · [Deutsch](README.de.md) · [Español](README.es.md) · **日本語** · [简体中文](README.zh-CN.md) このコンポーネントは、ZCode Agent Kit に同梱されるローカルモデル -プロキシを提供します。セットアップとアシスタントとの連携は Kit が -管理します。 +プロキシを提供します。セットアップは Kit が管理し、アシスタントとの +連携は同意を得た場合だけ設定します。 ## ZCode Agent Kit での使用 diff --git a/zcode-proxy-src/README.md b/zcode-proxy-src/README.md index fbe7241..02b984b 100644 --- a/zcode-proxy-src/README.md +++ b/zcode-proxy-src/README.md @@ -2,7 +2,7 @@ **English (original)** · [Deutsch](README.de.md) · [Español](README.es.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) This component provides the local model proxy bundled with ZCode Agent Kit. -The Kit manages its setup and integration with assistant tools. +The Kit manages its setup; assistant integrations are configured only with your consent. ## Use with ZCode Agent Kit diff --git a/zcode-proxy-src/README.zh-CN.md b/zcode-proxy-src/README.zh-CN.md index ba54569..b918b69 100644 --- a/zcode-proxy-src/README.zh-CN.md +++ b/zcode-proxy-src/README.zh-CN.md @@ -1,8 +1,8 @@ # ZCode Proxy [English (original)](README.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [日本語](README.ja.md) · **简体中文** -此组件提供 ZCode Agent Kit 随附的本地模型代理。Kit 负责其安装配置以及 -与助手工具的集成。 +此组件提供 ZCode Agent Kit 随附的本地模型代理。Kit 负责其安装配置; +只有在获得你的同意后才配置助手集成。 ## 在 ZCode Agent Kit 中使用 diff --git a/zcode-proxy-src/src/auth/account-rotator.test.ts b/zcode-proxy-src/src/auth/account-rotator.test.ts index f62a558..c802714 100644 --- a/zcode-proxy-src/src/auth/account-rotator.test.ts +++ b/zcode-proxy-src/src/auth/account-rotator.test.ts @@ -91,4 +91,36 @@ describe("account rotator", () => { expect(rotator.list().find((a) => a.id === "a")?.state).toBe("paused"); expect(rotator.list().find((a) => a.id === "b")?.state).toBe("paused"); }); + + it("keeps the inference account sticky when a billing/quota/async lookup needs a JWT it lacks", () => { + const rotator = createAccountRotator([ + { id: "key-only", credential: { provider: "zai", apiKey: "key-a" } }, + { id: "with-jwt", credential: { provider: "zai", apiKey: "key-b", jwt: "jwt-b" } }, + ]); + expect(rotator.getCredentialHandle().id).toBe("key-only"); + for (const operation of ["billing", "quota", "async"] as const) { + // The lookup is served by the JWT-bearing profile ... + expect(rotator.getCredentialHandle({ operation }).id).toBe("with-jwt"); + // ... but inference stays on the account it was using: a control-plane + // lookup is not an allowed switch reason. + expect(rotator.getCredentialHandle().id).toBe("key-only"); + expect(rotator.getCredentialHandle({ operation: "inference" }).id).toBe("key-only"); + } + expect(rotator.getSelectedId()).toBe("key-only"); + }); + + it("treats the same wire token as one identity regardless of userId metadata", () => { + const rotator = createAccountRotator([ + { id: "imported", credential: { provider: "zai", apiKey: "k", jwt: "jwt-same" } }, + { id: "oauth", credential: { provider: "zai", apiKey: "k", jwt: "jwt-same", userId: "user-1" } }, + { id: "other", credential: { provider: "zai", apiKey: "k2", jwt: "jwt-other", userId: "user-2" } }, + ], { plan: "start-plan" }); + const first = rotator.getCredentialHandle(); + expect(first.id).toBe("imported"); + expect(rotator.handleForId("oauth")?.effectiveIdentity).toBe(first.effectiveIdentity); + rotator.markExhausted(first, "1005"); + // The quarantined token must not be resent through its metadata alias. + expect(rotator.getCredentialHandle().id).toBe("other"); + expect(rotator.canResendHandle(rotator.handleForId("oauth")!)).toBe(false); + }); }); diff --git a/zcode-proxy-src/src/auth/account-rotator.ts b/zcode-proxy-src/src/auth/account-rotator.ts index 2795dc3..5703b2c 100644 --- a/zcode-proxy-src/src/auth/account-rotator.ts +++ b/zcode-proxy-src/src/auth/account-rotator.ts @@ -115,11 +115,17 @@ function sameCredential(a: Credential, b: Credential): boolean { && a.jwt === b.jwt && a.userId === b.userId && a.expiresAt === b.expiresAt; } -/** Hash the effective upstream identity, never retaining the token itself. */ +/** + * Hash the effective upstream identity, never retaining the token itself. + * Only the authentication material counts: two profiles that send the same + * wire token are one upstream identity even when one of them carries a + * `userId` (OAuth login) and the other does not (desktop import), so a + * quarantine of either blocks both. + */ function effectiveIdentity(credential: Credential, plan?: AccountPlan): string { const token = plan === "start-plan" ? credential.jwt ?? "" : credentialString(credential); return createHash("sha256") - .update(JSON.stringify([credential.provider, plan ?? "", token, credential.userId ?? ""])) + .update(JSON.stringify([credential.provider, plan ?? "", token])) .digest("hex"); } @@ -287,7 +293,11 @@ export class AccountRotator { ? candidates.find((candidate) => candidate.id === this.activeAccountId) : undefined; const account = active ?? candidates[0]; - this.activeAccountId = account.id; + // Only inference moves the sticky pointer. Billing/quota/async lookups + // require a JWT the active account may lack; serving them from another + // profile must not switch the identity that later inference requests use. + const inference = options.operation === undefined || options.operation === "inference"; + if (inference) this.activeAccountId = account.id; account.lastUsedAt = now; this.lastSelectedId = account.id; return { diff --git a/zcode-proxy-src/src/proxy/handler.ts b/zcode-proxy-src/src/proxy/handler.ts index ef0def3..31469fb 100644 --- a/zcode-proxy-src/src/proxy/handler.ts +++ b/zcode-proxy-src/src/proxy/handler.ts @@ -935,7 +935,9 @@ function nextReqId(): string { } const DEBUG_BODY_PREVIEW = 200; -const SENSITIVE_HEADERS = new Set(["authorization", "x-api-key", "cookie", "set-cookie", "proxy-authorization"]); +// Captcha verify tokens are single-use bearer material for the start-plan +// gateway: debug output masks them like credentials. +const SENSITIVE_HEADERS = new Set(["authorization", "x-api-key", "cookie", "set-cookie", "proxy-authorization", "x-aliyun-captcha-verify-param"]); function debugLine(reqId: string, msg: string): void { console.log(`${reqId} debug: ${msg}`); diff --git a/zcode-proxy-src/src/proxy/ordered-transport.test.ts b/zcode-proxy-src/src/proxy/ordered-transport.test.ts index bea35b3..1c72a3b 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.test.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.test.ts @@ -108,6 +108,35 @@ describe("sendOrderedUpstreamRequest — abort propagation", () => { } }); + it("aborts during connection setup: a stalled TLS handshake is torn down promptly", async () => { + // A plain TCP listener that never answers the TLS ClientHello: the connect + // phase stalls before the request-phase abort listener could exist. + const { createServer: createTcpServer } = await import("node:net"); + let accepted = 0; + const tcp = createTcpServer(() => { accepted += 1; }); + await new Promise((r) => tcp.listen(0, "127.0.0.1", r)); + try { + const port = (tcp.address() as AddressInfo).port; + const controller = new AbortController(); + const started = Date.now(); + const promise = sendOrderedUpstreamRequest({ + url: `https://127.0.0.1:${port}/v1/messages`, + method: "POST", + headers: [["content-type", "application/json"]], + body: "{}", + signal: controller.signal, + }); + expect(await waitFor(() => accepted > 0)).toBe(true); + controller.abort(); + // The contract: the pending connect settles promptly with the abort + // error instead of waiting for the OS handshake timeout. + await expect(promise).rejects.toThrow(/abort/); + expect(Date.now() - started).toBeLessThan(5000); + } finally { + tcp.close(); + } + }); + it("pre-aborted signal: rejects before anything reaches the wire", async () => { const s = await startSilentServer(); try { diff --git a/zcode-proxy-src/src/proxy/ordered-transport.ts b/zcode-proxy-src/src/proxy/ordered-transport.ts index 8a6914c..ee7b7ef 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.ts @@ -1,4 +1,4 @@ -import { connect as connectTcp, type Socket } from "node:net"; +import { connect as connectTcp, isIP, type Socket } from "node:net"; import { connect as connectTls, type TLSSocket } from "node:tls"; import { decodeContentStream } from "./inflate.js"; @@ -24,7 +24,7 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P const url = new URL(req.url); const bodyBytes = bodyToBytes(req.body); const requestHead = buildRequestHead(url, req.method ?? "POST", req.headers, bodyBytes.byteLength); - const socket = await openSocket(url); + const socket = await openSocket(url, req.signal); return await new Promise((resolve, reject) => { let headerBuffer: Uint8Array = new Uint8Array(0); @@ -175,22 +175,43 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P }); } -function openSocket(url: URL): Promise { +/** + * Open the TCP/TLS connection. The client signal is honoured here as well: + * a client that disappears while the connect or TLS handshake stalls must + * not leave a pending socket behind (the abort listener of the request phase + * is only installed after this resolves). + */ +function openSocket(url: URL, signal?: AbortSignal): Promise { const isHttps = url.protocol === "https:"; if (!isHttps && url.protocol !== "http:") { return Promise.reject(new Error(`Unsupported upstream protocol: ${url.protocol}`)); } const port = Number(url.port || (isHttps ? 443 : 80)); + if (signal?.aborted) return Promise.reject(new Error("client aborted before the upstream connection was opened")); return new Promise((resolve, reject) => { + const onAbort = () => { + socket.off("error", onError); + socket.destroy(); + reject(new Error("client aborted during the upstream connection setup")); + }; + const onError = (err: unknown) => { + signal?.removeEventListener("abort", onAbort); + reject(err); + }; const onConnect = () => { - socket.off("error", reject); + socket.off("error", onError); + signal?.removeEventListener("abort", onAbort); resolve(socket); }; + // SNI carries host names only; Bun rejects an IP literal where Node + // silently drops it, so send it only for a real host name. + const servername = isIP(url.hostname) === 0 ? url.hostname : undefined; const socket: WireSocket = isHttps - ? connectTls({ host: url.hostname, port, servername: url.hostname }, onConnect) + ? connectTls({ host: url.hostname, port, ...(servername ? { servername } : {}) }, onConnect) : connectTcp({ host: url.hostname, port }, onConnect); - socket.once("error", reject); + socket.once("error", onError); + signal?.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts b/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts index 5380318..9f74e0c 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts @@ -128,6 +128,29 @@ describe("account-pool upstream recovery", () => { } }); + it("never fails over when the same-account retry died after the request was written (possible duplicate execution)", async () => { + const auth = poolAuth(); + const sent: string[] = []; + const pending = recoverAndMapUpstream({ + response: Response.json({ code: 1005, msg: "redacted upstream text" }, { status: 200 }), + auth, + credential: first, + plan: "coding-plan", + signal: new AbortController().signal, + quotaRetryDelaysMs: [0], + resend: async (credential) => { + sent.push(credential.apiKey); + const err = new Error("socket closed after the request was written") as Error & { postWrite?: boolean }; + err.postWrite = true; + throw err; + }, + }); + await expect(pending).rejects.toThrow(/after the request was written/); + // The retry went to the same account only; account two never received the prompt. + expect(sent).toEqual(["pool-one"]); + expect(auth.listAccounts().find((a) => a.id === "two")?.state).not.toBe("exhausted"); + }); + it("aborts during the retry wait without sending anything or memoizing", async () => { // Legacy auth (no rotator): canResendCredential reflects only the retry // memo, so this pins that an aborted schedule proves nothing. In pool diff --git a/zcode-proxy-src/src/proxy/upstream-errors.ts b/zcode-proxy-src/src/proxy/upstream-errors.ts index 3e2cf31..67c11b6 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors.ts @@ -196,7 +196,12 @@ export async function recoverAndMapUpstream(opts: { if (!retriedQuota) break; // served from a package with balance — done // A SENT retry came back exhausted: this is real evidence for the memo. confirmedExhausted = true; - } catch { + } catch (err) { + // A failure after the full request was written may have been + // processed upstream: never fail over to another account on top of + // it (the connect ladder refuses the same replay). The handler maps + // the rethrown error to a 502 without a further attempt. + if ((err as { postWrite?: unknown } | null)?.postWrite) throw err; // The retry could not be sent (connect or captcha failure): keep the // current quota envelope so rotation below is not lost. scheduleCompleted = false; From dcccee844c4e5ce4ecb696f3686c61d3d699b355 Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 14:55:26 +0000 Subject: [PATCH 2/6] docs: state the kit-owned refresh path and the manual entry wording precisely Counter-check findings (documentation vs. code): the README sentence about unattended runs now names the refresh of an integration the kit created before it asked (no consent recorded, no MCP), the manual entry paragraph no longer calls the kit's own step "automatic configuration", setup.mjs no longer claims to wire every detected harness, and one pre-existing nuance in the Chinese proxy section (start lock is never taken over, not "kept") is corrected. Same wording in de/es/ja/zh-CN. --- README.de.md | 4 ++-- README.es.md | 4 ++-- README.ja.md | 4 ++-- README.md | 4 ++-- README.zh-CN.md | 6 +++--- setup.mjs | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.de.md b/README.de.md index d1fca4b..3593348 100644 --- a/README.de.md +++ b/README.de.md @@ -22,7 +22,7 @@ Behalte deinen Coding-Assistenten. Nutze deine vorhandenen ZCode-Modelle und dei ## Schritt 2 — Kit einmal installieren -Wähle unten den Release-Installer oder npm. Der Release-Installer führt das Setup für dich aus; ein separater Setup-Befehl ist danach nicht nötig. Das Setup stellt pro erkanntem Assistenten eine Frage, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (mit dem Namen des Assistenten), und ändert dessen Konfiguration nur nach einem `y`; `n` überspringt ihn und lässt seine Einstellungen unverändert. Ohne Antwort im Terminal wird ein Assistent nur eingerichtet, wenn du ihn ausdrücklich auswählst (siehe unten) oder früher zugestimmt hast. +Wähle unten den Release-Installer oder npm. Der Release-Installer führt das Setup für dich aus; ein separater Setup-Befehl ist danach nicht nötig. Das Setup stellt pro erkanntem Assistenten eine Frage, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (mit dem Namen des Assistenten), und ändert dessen Konfiguration nur nach einem `y`; `n` überspringt ihn und lässt seine Einstellungen unverändert. Ohne Antwort im Terminal wird nichts Neues eingerichtet: Ein Assistent wird nur konfiguriert, wenn du ihn ausdrücklich auswählst (siehe unten) oder früher zugestimmt hast, und eine Integration, die das Kit vor der Frage angelegt hat, wird nur aktuell gehalten. Der Installer zeigt vier nummerierte Schritte, eine Ergebniszeile pro Assistent (eingerichtet, übersprungen oder fehlgeschlagen), eine Verbindungsprüfung und die Verbindungsdaten für die manuelle Client-Einrichtung. Ausführliche Setup-Ausgaben stehen in der angezeigten `install.log`; mit `ZCODE_KIT_VERBOSE=1` siehst du die vollständige Ausgabe und mit `NO_COLOR=1` einfachen Text. Bei interaktiver Installation wird pro Assistent und getrennt davon für den Account Rotator nach `y` oder `n` gefragt; Strg-C beendet die Fragen, und alles, was du nicht mit `y` beantwortet hast, bleibt unverändert. Für unbeaufsichtigte Installationen wählst du Assistenten ausdrücklich mit `ZCODE_KIT_HARNESSES=omp,codex` (oder `none`) und setzt `ZCODE_KIT_ACCOUNT_ROTATOR=y` oder `n`; ohne ausdrückliche Auswahl werden unentschiedene Assistenten übersprungen, und die bisherige Account-Rotator-Einstellung bleibt erhalten. Antworten werden unter `generated/harness-choices/` gespeichert: Ein späteres Setup, Update oder `zcode-kit doctor --fix` aktualisiert nur Assistenten, denen du zugestimmt hast (ein `y`, eine ausdrückliche Auswahl oder `zcode-kit integrate `), und richtet einen übersprungenen nie erneut ein; eine Integration, die das Kit vor der Frage angelegt hat, wird aktuell gehalten, gilt aber erst nach einem `y` als Zustimmung. `zcode-kit setup --reask` stellt die Frage im Terminal für jeden erkannten Assistenten erneut. Eine fehlgeschlagene Verbindungsprüfung bleibt eine Warnung, auch wenn die Installation erfolgreich war. @@ -82,7 +82,7 @@ Connection details (local ZCode proxy, running and verified) Model IDs: glm-5.3, glm-5.3-flash ``` -Trage die Basis-URL ein, die zum API-Format deines Clients passt, den Schlüssel als API-Schlüssel oder Auth-Token und eine der Modell-IDs. Der vollständige Schlüssel wird nur in einem interaktiven Terminal ausgegeben; `zcode-kit models --show-key` gibt ihn für Skripte aus, und `zcode-kit proxy status` kennzeichnet die Werte als nicht verifiziert, solange der Proxy nicht läuft. Auch dieser Weg braucht den Proxy des Kits und einen ZCode-Login mit Kontingent; er ersetzt nur die automatische Assistenten-Konfiguration. Der Port stammt aus deiner `proxy/config.yaml`. +Trage die Basis-URL ein, die zum API-Format deines Clients passt, den Schlüssel als API-Schlüssel oder Auth-Token und eine der Modell-IDs. Der vollständige Schlüssel wird nur in einem interaktiven Terminal ausgegeben; `zcode-kit models --show-key` gibt ihn für Skripte aus, und `zcode-kit proxy status` kennzeichnet die Werte als nicht verifiziert, solange der Proxy nicht läuft. Auch dieser Weg braucht den Proxy des Kits und einen ZCode-Login mit Kontingent; er erspart dir nur, dass das Kit einen Assistenten für dich konfiguriert. Der Port stammt aus deiner `proxy/config.yaml`. **OMP:** diff --git a/README.es.md b/README.es.md index 2b3823f..c1e823d 100644 --- a/README.es.md +++ b/README.es.md @@ -22,7 +22,7 @@ Conserva tu asistente de programación. Usa tus modelos y tu cuota de ZCode medi ## Paso 2 — Instala el kit una vez -Elige el instalador de la versión publicada o npm. El instalador ejecuta la configuración por ti, así que no hace falta ejecutar otro comando de configuración después. La configuración hace una pregunta por asistente detectado, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (con el nombre del asistente), y solo cambia la configuración de ese asistente tras un `y`; `n` lo omite y deja sus ajustes intactos. Sin una respuesta en la terminal, un asistente solo se configura si lo seleccionas explícitamente (ver abajo) o ya diste tu consentimiento antes. +Elige el instalador de la versión publicada o npm. El instalador ejecuta la configuración por ti, así que no hace falta ejecutar otro comando de configuración después. La configuración hace una pregunta por asistente detectado, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (con el nombre del asistente), y solo cambia la configuración de ese asistente tras un `y`; `n` lo omite y deja sus ajustes intactos. Sin una respuesta en la terminal no se configura nada nuevo: un asistente solo se configura si lo seleccionas explícitamente (ver abajo) o ya diste tu consentimiento antes, y una integración que el kit creó antes de preguntar solo se mantiene al día. El instalador muestra cuatro etapas numeradas, una línea de resultado por asistente (configurado, omitido o fallido), una prueba de conexión y los datos de conexión para configurar clientes manualmente. El resultado detallado de la configuración se guarda en el `install.log` indicado; usa `ZCODE_KIT_VERBOSE=1` para ver todo o `NO_COLOR=1` para texto sin formato. La instalación interactiva pregunta `y` o `n` por cada asistente y, por separado, por Account Rotator; Ctrl-C detiene las preguntas y deja intacto todo lo que no hayas respondido con `y`. Para instalaciones desatendidas, selecciona los asistentes explícitamente con `ZCODE_KIT_HARNESSES=omp,codex` (o `none`) y establece `ZCODE_KIT_ACCOUNT_ROTATOR=y` o `n`; sin una selección explícita, los asistentes sin decidir se omiten y se conserva la configuración existente de Account Rotator. Las respuestas se guardan en `generated/harness-choices/`: una configuración posterior, `update` o `zcode-kit doctor --fix` solo actualizan los asistentes que consentiste (un `y`, una selección explícita o `zcode-kit integrate `) y nunca vuelven a integrar uno omitido; una integración que el kit creó antes de preguntar se mantiene al día, pero solo cuenta como consentimiento cuando respondes `y`. `zcode-kit setup --reask` vuelve a preguntar por cada asistente detectado en una terminal. Si falla la prueba de conexión, seguirá siendo una advertencia aunque la instalación haya terminado correctamente. @@ -82,7 +82,7 @@ Connection details (local ZCode proxy, running and verified) Model IDs: glm-5.3, glm-5.3-flash ``` -Introduce la URL base que corresponda al formato de API de tu cliente, la clave como clave API o token de autenticación y uno de los ID de modelo. La clave completa solo se imprime en una terminal interactiva; `zcode-kit models --show-key` la imprime para scripts, y `zcode-kit proxy status` marca los valores como no verificados mientras el proxy no está en ejecución. Esta vía sigue necesitando el proxy del kit y una sesión de ZCode con cuota; solo elimina la configuración automática del asistente. El puerto procede de tu `proxy/config.yaml`. +Introduce la URL base que corresponda al formato de API de tu cliente, la clave como clave API o token de autenticación y uno de los ID de modelo. La clave completa solo se imprime en una terminal interactiva; `zcode-kit models --show-key` la imprime para scripts, y `zcode-kit proxy status` marca los valores como no verificados mientras el proxy no está en ejecución. Esta vía sigue necesitando el proxy del kit y una sesión de ZCode con cuota; solo evita que el kit configure un asistente por ti. El puerto procede de tu `proxy/config.yaml`. **OMP:** diff --git a/README.ja.md b/README.ja.md index 6c1d8f3..fc77674 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,7 +22,7 @@ ## ステップ 2 — kit を一度インストール -以下のリリース版インストーラーまたは npm を選びます。リリース版インストーラーはセットアップも実行するため、後から別のセットアップコマンドを実行する必要はありません。セットアップは検出したアシスタントごとに **"Configure ZCode as a provider with its supported models in ? [y/n]"**(アシスタント名入り)と 1 回ずつ質問し、`y` と答えた場合だけそのアシスタントの設定を変更します。`n` はスキップし、設定には触れません。ターミナルでの回答がない場合、アシスタントが設定されるのは明示的に選択したとき(後述)か、以前に同意したときだけです。 +以下のリリース版インストーラーまたは npm を選びます。リリース版インストーラーはセットアップも実行するため、後から別のセットアップコマンドを実行する必要はありません。セットアップは検出したアシスタントごとに **"Configure ZCode as a provider with its supported models in ? [y/n]"**(アシスタント名入り)と 1 回ずつ質問し、`y` と答えた場合だけそのアシスタントの設定を変更します。`n` はスキップし、設定には触れません。ターミナルでの回答がない場合、新しく設定されるものはありません。アシスタントが設定されるのは明示的に選択したとき(後述)か、以前に同意したときだけで、質問より前に kit が作成した統合は最新に保たれるだけです。 インストーラーは番号付きの 4 段階、アシスタントごとの結果行(設定済み、スキップ、失敗)、接続確認、および手動でクライアントを設定するための接続情報を表示します。詳しいセットアップ出力は表示された `install.log` に保存されます。`ZCODE_KIT_VERBOSE=1` で全出力、`NO_COLOR=1` でプレーンテキストにできます。対話的なインストールでは、アシスタントごとに、さらに別途 Account Rotator について `y` または `n` を尋ねます。Ctrl-C で質問を中止でき、`y` と答えていないものはすべてそのままです。無人インストールでは `ZCODE_KIT_HARNESSES=omp,codex`(または `none`)でアシスタントを明示的に選択し、`ZCODE_KIT_ACCOUNT_ROTATOR=y` または `n` を指定してください。明示的な選択がなければ、未決定のアシスタントはスキップされ、既存の Account Rotator 設定が維持されます。回答は `generated/harness-choices/` に保存されます。その後のセットアップ、update、`zcode-kit doctor --fix` は同意したアシスタント(`y`、明示的な選択、または `zcode-kit integrate <アシスタント>`)だけを更新し、スキップしたアシスタントを再統合することはありません。質問より前に kit が作成した統合は最新に保たれますが、`y` と答えるまで同意とは見なされません。`zcode-kit setup --reask` はターミナルで、検出したすべてのアシスタントについて改めて質問します。接続確認に失敗しても、インストール自体が成功した場合は警告として扱われます。 @@ -82,7 +82,7 @@ Connection details (local ZCode proxy, running and verified) Model IDs: glm-5.3, glm-5.3-flash ``` -クライアントの API 形式に合うベース URL、API キーまたは認証トークンとしてのキー、モデル ID のいずれかを入力します。完全なキーは対話的なターミナルでのみ表示されます。スクリプト向けには `zcode-kit models --show-key` が出力し、プロキシが動いていない間は `zcode-kit proxy status` が値を未検証として表示します。この方法でも kit のプロキシと利用枠のある ZCode ログインは必要で、省略できるのはアシスタントの自動設定だけです。ポートは `proxy/config.yaml` から取得されます。 +クライアントの API 形式に合うベース URL、API キーまたは認証トークンとしてのキー、モデル ID のいずれかを入力します。完全なキーは対話的なターミナルでのみ表示されます。スクリプト向けには `zcode-kit models --show-key` が出力し、プロキシが動いていない間は `zcode-kit proxy status` が値を未検証として表示します。この方法でも kit のプロキシと利用枠のある ZCode ログインは必要で、省略できるのは、kit にアシスタントを設定させる手順だけです。ポートは `proxy/config.yaml` から取得されます。 **OMP:** diff --git a/README.md b/README.md index e24c22d..cc08b59 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Keep your coding assistant. Use your existing ZCode models and quota/tokens thro ## Step 2 — Install the kit once -Choose the release installer or npm below. The release installer runs setup for you, so there is no separate setup command to run afterward. Setup asks one question per detected assistant, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (with the assistant's name), and changes that assistant's configuration only after a `y`; `n` skips it and leaves its settings untouched. Without a terminal answer an assistant is configured only when you select it explicitly (see below) or consented earlier. +Choose the release installer or npm below. The release installer runs setup for you, so there is no separate setup command to run afterward. Setup asks one question per detected assistant, **"Configure ZCode as a provider with its supported models in ? [y/n]"** (with the assistant's name), and changes that assistant's configuration only after a `y`; `n` skips it and leaves its settings untouched. Without a terminal answer nothing new is set up: an assistant is configured only when you select it explicitly (see below) or consented earlier, and an integration the kit created before it asked is only kept current. The installer shows four numbered stages, one result line per assistant (configured, skipped or failed), a connection check and the connection details for manual client setup. Detailed setup output is saved to the displayed `install.log`; set `ZCODE_KIT_VERBOSE=1` for full output and `NO_COLOR=1` for plain text. Interactive installs ask `y` or `n` per assistant and, separately, for Account Rotator; Ctrl-C stops the questions and leaves everything you have not answered `y` to untouched. For unattended installs, select assistants explicitly with `ZCODE_KIT_HARNESSES=omp,codex` (or `none`) and set `ZCODE_KIT_ACCOUNT_ROTATOR=y` or `n`; without an explicit selection, undecided assistants are skipped and the existing Account Rotator setting is preserved. Answers are stored under `generated/harness-choices/`: a later setup, update or `zcode-kit doctor --fix` refreshes only assistants you consented to (a `y`, an explicit selection or `zcode-kit integrate `) and never re-integrates a skipped one; an integration the kit created before it asked is kept current but only counts as consent once you answer `y`. `zcode-kit setup --reask` asks every detected assistant again on a terminal. A failed connection check remains a warning even when installation succeeds. @@ -82,7 +82,7 @@ Connection details (local ZCode proxy, running and verified) Model IDs: glm-5.3, glm-5.3-flash ``` -Enter the base URL that matches your client's API format, the key as the API key or auth token, and one of the model IDs. The full key is printed only in an interactive terminal; `zcode-kit models --show-key` prints it for scripts, and `zcode-kit proxy status` labels the values as unverified while the proxy is not running. This path still needs the kit's proxy and a ZCode login with quota; it only removes the automatic assistant configuration. The port comes from your `proxy/config.yaml`. +Enter the base URL that matches your client's API format, the key as the API key or auth token, and one of the model IDs. The full key is printed only in an interactive terminal; `zcode-kit models --show-key` prints it for scripts, and `zcode-kit proxy status` labels the values as unverified while the proxy is not running. This path still needs the kit's proxy and a ZCode login with quota; it only skips having the kit configure an assistant for you. The port comes from your `proxy/config.yaml`. **OMP:** diff --git a/README.zh-CN.md b/README.zh-CN.md index 043d9cf..abeaa1c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ ## 第 2 步 — 安装一次 Kit -在下面的正式版安装器和 npm 之间选择一种方式。正式版安装器会替你运行设置,无须再单独运行设置命令。设置会对每个检测到的助手提问一次:**"Configure ZCode as a provider with its supported models in ? [y/n]"**(其中是助手名称),只有回答 `y` 后才修改该助手的配置;回答 `n` 会跳过它,其设置保持不变。没有终端回答时,只有在你明确选择(见下文)或此前已同意的情况下才会配置助手。 +在下面的正式版安装器和 npm 之间选择一种方式。正式版安装器会替你运行设置,无须再单独运行设置命令。设置会对每个检测到的助手提问一次:**"Configure ZCode as a provider with its supported models in ? [y/n]"**(其中是助手名称),只有回答 `y` 后才修改该助手的配置;回答 `n` 会跳过它,其设置保持不变。没有终端回答时不会新增任何配置:只有在你明确选择(见下文)或此前已同意的情况下才会配置助手,Kit 在提问之前创建的集成只会保持更新。 安装器会显示四个编号步骤、每个助手一行结果(已配置、已跳过或失败)、连接检查,以及供手动配置客户端使用的连接详情。详细的设置输出保存在屏幕提示的 `install.log` 中;设置 `ZCODE_KIT_VERBOSE=1` 可显示全部输出,设置 `NO_COLOR=1` 可显示纯文本。交互式安装会对每个助手以及(单独地)账号轮换问题询问 `y` 或 `n`;按 Ctrl-C 会停止提问,所有未回答 `y` 的项目保持不变。无人值守安装请用 `ZCODE_KIT_HARNESSES=omp,codex`(或 `none`)明确选择助手,并设置 `ZCODE_KIT_ACCOUNT_ROTATOR=y` 或 `n`;没有明确选择时,未决定的助手会被跳过,原有的账号轮换设置保持不变。回答保存在 `generated/harness-choices/` 中:之后的设置、update 或 `zcode-kit doctor --fix` 只会刷新你已同意的助手(回答 `y`、明确选择或 `zcode-kit integrate <助手>`),绝不会重新集成已跳过的助手;Kit 在提问之前创建的集成会保持更新,但只有在你回答 `y` 后才算作同意。`zcode-kit setup --reask` 会在终端中对每个检测到的助手重新提问。即使安装成功,连接检查失败仍会显示为警告。 @@ -82,7 +82,7 @@ Connection details (local ZCode proxy, running and verified) Model IDs: glm-5.3, glm-5.3-flash ``` -填入与客户端 API 格式匹配的基础 URL,把密钥作为 API 密钥或认证令牌填入,并选择一个模型 ID。完整密钥只会在交互式终端中输出;`zcode-kit models --show-key` 可为脚本输出密钥,代理未运行时 `zcode-kit proxy status` 会把这些值标记为未验证。此方式仍需要 Kit 的代理以及有额度的 ZCode 登录;它只省去自动配置助手这一步。端口来自你的 `proxy/config.yaml`。 +填入与客户端 API 格式匹配的基础 URL,把密钥作为 API 密钥或认证令牌填入,并选择一个模型 ID。完整密钥只会在交互式终端中输出;`zcode-kit models --show-key` 可为脚本输出密钥,代理未运行时 `zcode-kit proxy status` 会把这些值标记为未验证。此方式仍需要 Kit 的代理以及有额度的 ZCode 登录;它只省去让 Kit 为你配置助手这一步。端口来自你的 `proxy/config.yaml`。 **OMP:** @@ -161,7 +161,7 @@ zcode-kit proxy stop **挂起的代理:**只有在证明无响应的代理属于本 Kit 时,`start`、`restart` 和 `stop` 才会终止它:已超过 60 秒启动宽限期、启动时间与记录一致、命令行是 Kit 代理,并且连续 3 次健康检查(约 25 秒)失败。归属未知的进程绝不会被终止;命令会报告情况并停止。`zcode-kit doctor --fix` 会重新应用受管配置;如果代理未运行或经证明已挂起,也会以同样方式启动它。 -**自动重启:**如果代理主线程停止响应或内存持续过高,代理会请求 Kit 管理器重新启动它。15 分钟内最多接受 3 次此类重启;超过次数或重启历史无法读取时,请求会被拒绝,代理保持停止,直到你检查 `zcode-kit proxy logs 50` 并启动它。遗留的启动锁会被刻意保留、从不自动接管:如果没有正在进行的启动,请删除消息中指明的锁文件后重试。 +**自动重启:**如果代理主线程停止响应或内存持续过高,代理会请求 Kit 管理器重新启动它。15 分钟内最多接受 3 次此类重启;超过次数或重启历史无法读取时,请求会被拒绝,代理保持停止,直到你检查 `zcode-kit proxy logs 50` 并启动它。遗留的启动锁绝不会被自动接管:如果没有正在进行的启动,请删除消息中指明的锁文件后重试。 diff --git a/setup.mjs b/setup.mjs index c7db194..1564482 100644 --- a/setup.mjs +++ b/setup.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // ZCode access kit — one-command setup for a fresh clone (compat entry). // -// node setup.mjs # bootstrap + wire all detected harnesses +// node setup.mjs # bootstrap + one y/n question per detected harness // node setup.mjs --only=omp,mcp # subset: omp | claude-code | codex | mcp // node setup.mjs --rollback [tx-id] # undo the newest (or named) transaction // node setup.mjs --list-transactions # show recorded transaction ids @@ -19,7 +19,7 @@ const mode = argv[0] ?? "install"; const kit = join(ROOT, "cli", "zcode-kit.mjs"); if (mode === "--postinstall-hint") { - console.log("ZCode Agent Kit installed. Run zcode-kit setup to configure your detected harnesses."); + console.log("ZCode Agent Kit installed. Run zcode-kit setup to choose which detected harnesses to configure."); } else if (mode === "--rollback" || mode === "rollback") { const res = spawnSync(process.execPath, [kit, "rollback", ...(argv[1] ? [argv[1]] : [])], { stdio: "inherit" }); process.exit(res.status ?? 1); From 05d64fc1894239ea6313ea845d3e7a16ecf8dbbf Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 15:35:38 +0000 Subject: [PATCH 3/6] proxy: retry transient failures before output and end cut streams with an error; setup: separate MCP consent, exit code 20 Trigger: errors that disappeared when the user sent a new "Continue" message. The proxy retried only four connect-level error codes; every other transient failure ended the harness turn although a fresh request would have succeeded, and a natively passed-through Anthropic stream that the gateway cut off simply stopped. Proxy: - pre-output transient ladder (shared by chat and /v1/responses): thrown connect failures and connection drops before a response (reset, pipe, timeout; never postWrite, TLS or abort) and HTTP 500/502/503/504/524/529 and 429 without a recognised gateway envelope are re-dispatched on the same account, initial + 3 retries with growing, abortable waits; a numeric Retry-After is honoured up to 15 s and surfaced above it. Gateway business envelopes, request/auth/model errors, streams and anything after output are never retried; the 1005/1113 schedule and the captcha layer keep their own single attempts. ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS sets the base delay (0 = no waits, off = connect-only as before) and is passed through by proxy start/restart. - ordered transport: an upstream close before response headers now carries postWrite (it was replayable by the quota failover); nested postWrite causes are recognised; the error listener stays attached on abort; IPv6 literals get a bare host and no SNI. - native Anthropic streams that end without message_stop, or whose read fails, get one terminal `event: error` frame (upstream_incomplete / upstream_stream_error); nothing is replayed, no message_stop invented. - captcha retry re-checks the client signal before solving and before the resend; account list marks the inference account as active even after a lookup served by another profile; one shared sensitive-header set for debug and dump output. Kit: - MCP bridge consent is recorded separately from provider consent: an explicit selection, or a y after the MCP note; integrate, a refresh or a stored decision without the flag never register the bridge. - setup exits 20 (not 1) when a harness failed and the others were configured; installers and update treat 20 as partial success and 130 (Ctrl-C) as "finish later", both still start the proxy / the shim. - setup withholds connection details when a foreign service holds the port; the details say when the model list came from configuration; [::1] listeners keep their URL. - transaction backups use a monotonic counter (no name reuse after restoreSince); undo and decision recording cannot abort the run; integrate warns when a decision cannot be recorded; install.sh proves /dev/tty can be opened and gives setup /dev/null without a terminal. Docs (en + de/es/ja/zh-CN): error modes, retry knob, stream termination, MCP consent, exit code 20. Tests: transient-retry, sse-terminal, ordered transport pre-header EOF, nested postWrite, captcha abort, rotator active marker, MCP consent paths, backup collision, connection-details markers. --- cli/connection-details.mjs | 11 +- cli/harness-consent.mjs | 45 ++-- cli/zcode-kit.mjs | 98 +++++--- docs/ACCOUNT_ROTATOR.de.md | 4 +- docs/ACCOUNT_ROTATOR.md | 4 +- harnesses/README.de.md | 4 +- harnesses/README.es.md | 4 +- harnesses/README.ja.md | 4 +- harnesses/README.md | 13 +- harnesses/README.zh-CN.md | 4 +- install.ps1 | 5 +- install.sh | 13 +- lib/proxy-env.mjs | 2 +- lib/transaction.mjs | 6 +- proxy/zcode-proxy-manager.mjs | 7 +- tests/connection-details.test.mjs | 12 + tests/harness-consent.test.mjs | 46 +++- zcode-proxy-src/README.de.md | 2 + zcode-proxy-src/README.es.md | 2 + zcode-proxy-src/README.ja.md | 2 + zcode-proxy-src/README.md | 2 + zcode-proxy-src/README.zh-CN.md | 2 + .../src/auth/account-rotator.test.ts | 12 + zcode-proxy-src/src/auth/account-rotator.ts | 12 +- .../src/proxy/captcha-retry.test.ts | 39 ++++ zcode-proxy-src/src/proxy/captcha-retry.ts | 12 +- .../src/proxy/credential-recovery.test.ts | 14 +- zcode-proxy-src/src/proxy/dump.ts | 8 +- .../src/proxy/handler-resilience.test.ts | 70 +++++- zcode-proxy-src/src/proxy/handler.ts | 219 +++++++++++++++--- .../src/proxy/ordered-transport.test.ts | 40 +++- .../src/proxy/ordered-transport.ts | 49 +++- .../src/proxy/responses-handler.test.ts | 2 +- .../src/proxy/responses-handler.ts | 11 +- .../src/proxy/sse-terminal.test.ts | 93 ++++++++ zcode-proxy-src/src/proxy/sse-terminal.ts | 67 ++++++ .../src/proxy/transient-retry.test.ts | 184 +++++++++++++++ .../src/proxy/upstream-errors-pool.test.ts | 27 +++ zcode-proxy-src/src/proxy/upstream-errors.ts | 20 +- zcode-proxy-src/src/proxy/upstream.test.ts | 20 ++ 40 files changed, 1050 insertions(+), 141 deletions(-) create mode 100644 zcode-proxy-src/src/proxy/sse-terminal.test.ts create mode 100644 zcode-proxy-src/src/proxy/sse-terminal.ts create mode 100644 zcode-proxy-src/src/proxy/transient-retry.test.ts diff --git a/cli/connection-details.mjs b/cli/connection-details.mjs index a406de6..7eb4877 100644 --- a/cli/connection-details.mjs +++ b/cli/connection-details.mjs @@ -53,18 +53,23 @@ export function isLoopbackHost(host) { * Plain-text lines (no colour, no emojis). `source` is "running" only when * the caller verified the proxy answered as this installation's own. */ -export function connectionDetailsLines({ port, key, models = DEFAULT_MODEL_IDS, source = "configured", reveal = false, host = "127.0.0.1", responsesEnabled = true, indent = " " }) { +export function connectionDetailsLines({ port, key, models = DEFAULT_MODEL_IDS, modelsSource = "config", source = "configured", reveal = false, host = "127.0.0.1", responsesEnabled = true, indent = " " }) { const loopback = isLoopbackHost(host); - const base = `http://${loopback ? "127.0.0.1" : host}:${port}`; + // An IPv6-only listener is reachable as [::1], not as 127.0.0.1. + const urlHost = host === "::1" ? "[::1]" : loopback ? "127.0.0.1" : host; + const base = `http://${urlHost}:${port}`; const keyText = reveal && typeof key === "string" && key.length ? key : REDACTED_KEY; const verified = source === "running"; const openaiRoutes = responsesEnabled ? "POST /chat/completions, POST /responses, GET /models" : "POST /chat/completions, GET /models"; + // With a verified proxy the ids come from its live model list; say so when + // that list was unavailable and the configured list is shown instead. + const modelsNote = verified && modelsSource !== "live" ? " (from configuration; live model list unavailable)" : ""; const lines = [ `${indent}Connection details (local ZCode proxy, ${verified ? "running and verified" : "from configuration; proxy not verified running"})`, `${indent} OpenAI-compatible base URL: ${base}/v1 (${openaiRoutes})`, `${indent} Anthropic-compatible base URL: ${base} (POST /v1/messages)`, `${indent} API key (Bearer / x-api-key): ${keyText}`, - `${indent} Model IDs: ${models.join(", ")}`, + `${indent} Model IDs: ${models.join(", ")}${modelsNote}`, `${indent} Works with any OpenAI- or Anthropic-compatible client; no harness auto-configuration required.` + (verified ? "" : " Start the proxy first: zcode-kit proxy start"), ]; diff --git a/cli/harness-consent.mjs b/cli/harness-consent.mjs index b67df18..54bf068 100644 --- a/cli/harness-consent.mjs +++ b/cli/harness-consent.mjs @@ -178,7 +178,10 @@ export function readHarnessChoices(ctx) { try { const entry = JSON.parse(readFileSync(file, "utf8")); if (!entry || entry.schema !== 1 || (entry.decision !== "configured" && entry.decision !== "skipped")) throw new Error("unsupported format"); - result.harnesses[id] = { decision: entry.decision, source: String(entry.source ?? "unknown"), decidedAt: String(entry.decidedAt ?? "") }; + // `mcp` is separate consent for the kit's MCP bridge (recorded only + // after the MCP note was shown or the harness was selected explicitly); + // absent means no MCP consent, never "implied". + result.harnesses[id] = { decision: entry.decision, source: String(entry.source ?? "unknown"), decidedAt: String(entry.decidedAt ?? ""), mcp: entry.mcp === true }; } catch (err) { result.unreadable.push(id); result.errors.push(`${file}: ${err.message} — fix or remove the file; ${id} counts as undecided until then`); @@ -191,17 +194,22 @@ export function isUnreadableChoice(choices, id) { return choices.unreadable.includes("*") || choices.unreadable.includes(id); } -/** Record one decision through the transaction (rollback of that setup removes it again). */ -export function recordHarnessChoice(ctx, tx, choices, id, decision, source, now = () => new Date().toISOString()) { - if (ctx.dryRun) return choices; - if (isUnreadableChoice(choices, id)) return choices; // never overwrite what could not be read +/** + * Record one decision through the transaction (rollback of that setup removes + * it again). `mcp` records consent for the kit's MCP bridge separately from + * the provider consent; it is never inferred from a stored decision. + * Returns false when nothing could be recorded (unreadable decision file). + */ +export function recordHarnessChoice(ctx, tx, choices, id, decision, source, now = () => new Date().toISOString(), { mcp = false } = {}) { + if (ctx.dryRun) return true; + if (isUnreadableChoice(choices, id)) return false; // never overwrite what could not be read const previous = choices.harnesses[id]; - if (previous && previous.decision === decision && previous.source === source) return choices; - const entry = { schema: 1, harness: id, decision, source, decidedAt: now() }; + if (previous && previous.decision === decision && previous.source === source && previous.mcp === mcp) return true; + const entry = { schema: 1, harness: id, decision, source, decidedAt: now(), ...(mcp ? { mcp: true } : {}) }; ensureDir(ctx, harnessChoicesDir(ctx)); commitFile(ctx, tx, join(harnessChoicesDir(ctx), `${id}.json`), JSON.stringify(entry, null, 2) + "\n"); - choices.harnesses[id] = { decision, source, decidedAt: entry.decidedAt }; - return choices; + choices.harnesses[id] = { decision, source, decidedAt: entry.decidedAt, mcp }; + return true; } /** A kit-owned integration bound to THIS installation (adapter proof, never a shape guess). */ @@ -230,31 +238,38 @@ export function ownedIntegration(adapter, ctx) { * terminal and only refreshed (no consent recorded, no MCP) otherwise; * - otherwise the harness is asked; y configures, n skips, no answer skips * without changing the stored decision. - * `consent` marks decisions that authorize MCP registration for the harness. + * `consent` marks decisions that authorize the provider integration; `mcp` + * marks the narrower consent for the kit's MCP bridge: an explicit selection, + * or a y given after the MCP note was shown (`mcpOffered`), or a stored + * decision that recorded it. A refresh, an `integrate` decision or a y + * without the note never registers MCP. */ -export async function decideHarness({ id, label, detected, stored, unreadable = false, owned = false, explicit, reask = false, ask }) { +export async function decideHarness({ id, label, detected, stored, storedMcp = false, unreadable = false, owned = false, explicit, reask = false, ask, mcpOffered = false }) { const via = (source) => (source === "flag" ? "--harness" : HARNESS_SELECTION_ENV); // Re-asking needs a terminal: without one, stored decisions keep standing. const reasking = reask && typeof ask === "function"; if (explicit) { - if (explicit.ids.includes(id)) return { action: "configure", source: explicit.source, reason: `selected via ${via(explicit.source)}`, record: true, consent: true }; + // An explicit selection is documented to cover the MCP bridge (--no-mcp + // opts out), so it records MCP consent as well. + if (explicit.ids.includes(id)) return { action: "configure", source: explicit.source, reason: `selected via ${via(explicit.source)}`, record: true, consent: true, mcp: true }; if (explicit.none && detected) return { action: "skip", source: explicit.source, reason: `${via(explicit.source)}=none`, record: true, consent: false }; return { action: "ignore", source: explicit.source, reason: "not selected", record: false, consent: false }; } if (!detected) return { action: "ignore", source: "detect", reason: "not detected", record: false, consent: false }; if (unreadable) { const answer = ask ? await ask(harnessQuestion(label)) : undefined; - if (answer === true) return { action: "configure", source: "interactive", reason: "answered y (decision file unreadable — not recorded)", record: false, consent: true }; + if (answer === true) return { action: "configure", source: "interactive", reason: "answered y (decision file unreadable — not recorded)", record: false, consent: true, mcp: mcpOffered }; return { action: "skip", source: "none", reason: answer === false ? "answered n (decision file unreadable — not recorded)" : "decision file unreadable — fix or remove it", record: false, consent: false }; } - if (stored === "configured" && !reasking) return { action: "configure", source: "stored", reason: "previously configured", record: false, consent: true }; + if (stored === "configured" && !reasking) return { action: "configure", source: "stored", reason: "previously configured", record: false, consent: true, mcp: storedMcp === true }; if (stored === "skipped" && !reasking) return { action: "skip", source: "stored", reason: `previously skipped (change with: zcode-kit integrate ${id}, setup --harness ${id}, or setup --reask)`, record: false, consent: false }; if (!ask) { if (owned) return { action: "refresh", source: "existing", reason: "existing kit integration refreshed; consent not recorded (answer once on a terminal or select it explicitly)", record: false, consent: false }; return { action: "skip", source: "none", reason: NO_CONSENT_HINT, record: false, consent: false }; } const answer = await ask(harnessQuestion(label)); - if (answer === true) return { action: "configure", source: "interactive", reason: "answered y", record: true, consent: true, note: owned ? "existing kit integration found; y keeps it current" : undefined }; + // A y covers the MCP bridge only when the MCP note was shown before the question. + if (answer === true) return { action: "configure", source: "interactive", reason: "answered y", record: true, consent: true, mcp: mcpOffered, note: owned ? "existing kit integration found; y keeps it current" : undefined }; if (answer === false) return { action: "skip", source: "interactive", reason: "answered n", record: true, consent: false }; if (owned) return { action: "refresh", source: "existing", reason: "existing kit integration refreshed; no answer, consent not recorded", record: false, consent: false }; return { action: "skip", source: "none", reason: NO_CONSENT_HINT, record: false, consent: false }; diff --git a/cli/zcode-kit.mjs b/cli/zcode-kit.mjs index e212f5e..4f3aec6 100644 --- a/cli/zcode-kit.mjs +++ b/cli/zcode-kit.mjs @@ -21,11 +21,11 @@ // zcode-kit uninstall // // Exit codes: 0 ok · 1 checks failed · 2 runtime error · 3 port/foreign conflict · -// 4 safe-start refused (lock/ownership) · 5 auth/identity failure · 130 setup -// aborted with Ctrl-C. Unknown harness names are errors, not no-ops. `setup` -// exits 0 once the consented integrations are saved even if the optional live -// smoke request fails (it prints a warning); it exits 1 when one harness -// failed while the others were configured. +// 4 safe-start refused (lock/ownership) · 5 auth/identity failure · 20 setup: +// one harness failed while the others were configured · 130 setup aborted +// with Ctrl-C. Unknown harness names are errors, not no-ops. `setup` exits 0 +// once the consented integrations are saved even if the optional live smoke +// request fails (it prints a warning). import { beginTransaction, acquireLock, releaseLock, rollbackTransaction, listTransactions } from "../lib/transaction.mjs"; import { detectHarnesses } from "../lib/detect.mjs"; import { createCtx, bootstrap, kitRoot } from "./context.mjs"; @@ -60,6 +60,12 @@ import { isAbsolute, join, normalize } from "node:path"; import { pathToFileURL, fileURLToPath } from "node:url"; const ADAPTER_IDS = ["omp", "pi", "claude-code", "codex", "opencode", "cline", "kilo-code", "aider", "continue", "goose"]; +// setup: the kit is configured but at least one harness failed. A dedicated +// code (not 1, which Node uses for an uncaught error or a failed import) so +// the installers and `update` can tell a partial success from a crash. +const EXIT_HARNESS_FAILED = 20; +// setup: interrupted with Ctrl-C; answers already given were applied. +const EXIT_ABORTED = 130; function loadAdapter(id) { // eslint-disable-next-line no-bitwise @@ -142,8 +148,9 @@ function usage(code) { (proxy start/status and setup print the connection details for manual client setup; the key is shown in full only on an interactive terminal — export: zcode-kit models --show-key) -Exit codes: 0 ok · 1 checks failed (setup: at least one harness failed, the others are configured) · -2 runtime error · 3 port/foreign conflict · 4 safe-start refused · 5 auth/identity failure · +Exit codes: 0 ok · 1 checks failed · 2 runtime error · 3 port/foreign conflict · +4 safe-start refused · 5 auth/identity failure · +20 setup: at least one harness failed, the others are configured · 130 setup aborted with Ctrl-C (answers already given were applied) Harnesses: ${ADAPTER_IDS.join(", ")}`); @@ -254,11 +261,15 @@ async function cmdSetup() { continue; } const owned = detected[id] ? ownedIntegration(adapter, ctx) : false; + // MCP consent is separate from provider consent: a y covers the bridge + // only when this note was shown before the question (interactive) or + // the harness was selected explicitly; stored decisions carry it as a flag. + const mcpOffered = interactive && mcpBridgeAvailable && (id === "omp" || id === "claude-code"); // The question text is fixed; what a y implies beyond the provider // entry is said before it (existing integration, MCP bridge). const ask = interactive ? (question) => { if (owned) console.log(` note: an existing kit integration for ${adapter.label} was found; y keeps it current, n leaves it untouched.`); - if (mcpBridgeAvailable && (id === "omp" || id === "claude-code")) console.log(` note: y also registers the kit's MCP bridge "zcode-harness" for ${adapter.label} (skip with --no-mcp).`); + if (mcpOffered) console.log(` note: y also registers the kit's MCP bridge "zcode-harness" for ${adapter.label} (skip with --no-mcp).`); return prompter.ask(question); } : null; let decision; @@ -268,11 +279,13 @@ async function cmdSetup() { label: adapter.label, detected: Boolean(detected[id]), stored: choices.harnesses[id]?.decision, + storedMcp: choices.harnesses[id]?.mcp === true, unreadable: isUnreadableChoice(choices, id), owned, explicit, reask: flags.reask === true, ask, + mcpOffered, }); } catch (err) { if (err?.code !== ABORTED) throw err; @@ -284,7 +297,10 @@ async function cmdSetup() { } if (decision.action === "ignore") continue; if (decision.action === "skip") { - if (decision.record) recordHarnessChoice(ctx, tx, choices, id, "skipped", decision.source); + if (decision.record) { + try { recordHarnessChoice(ctx, tx, choices, id, "skipped", decision.source); } + catch (err) { ui.warn(`could not record the decision for ${adapter.label} (${err.message})`); } + } if (decision.reason === NO_CONSENT_HINT) undecided = true; outcome.skipped.push({ id, label: adapter.label, reason: decision.reason }); ui.result("skip", `${adapter.label} — ${decision.reason}`); @@ -296,14 +312,21 @@ async function cmdSetup() { try { const applied = adapter.apply(ctx, tx, (m) => { ui.detail(m); skipped ||= /\bskipped\b/i.test(m); warning ||= /\bWARN:/i.test(m); }); manual = applied?.manualConfirmationRequired === true; - if (decision.record) recordHarnessChoice(ctx, tx, choices, id, "configured", decision.source); + if (decision.record) recordHarnessChoice(ctx, tx, choices, id, "configured", decision.source, undefined, { mcp: decision.mcp === true }); } catch (err) { // One harness must not take the others down, and a half-applied // integration must not stay behind: undo this harness's own writes. - const undone = tx.restoreSince(savepoint); - const note = undone.conflicts.length - ? ` (left in place, check manually: ${undone.conflicts.join(", ")})` - : undone.restored.length + undone.removed.length ? " (its partial changes were undone)" : ""; + // The undo itself must not abort the run either — it reports instead. + let undone = { restored: [], removed: [], conflicts: [] }; + let note = ""; + try { + undone = tx.restoreSince(savepoint); + note = undone.conflicts.length + ? ` (left in place, check manually: ${undone.conflicts.join(", ")})` + : undone.restored.length + undone.removed.length ? " (its partial changes were undone)" : ""; + } catch (undoErr) { + note = ` (its partial changes could not be undone: ${undoErr.message}; see zcode-kit rollback)`; + } outcome.failed.push({ id, label: adapter.label, error: err.message + note }); ui.result("fail", `${adapter.label} — ${err.message}${note}`); continue; @@ -318,9 +341,11 @@ async function cmdSetup() { : warning ? " — review the warning above" : ""; outcome.configured.push({ id, label: adapter.label, refreshed: decision.action === "refresh", manual }); ui.result("ok", adapter.label + suffix); - if (decision.consent) mcpIds.push(id); + if (decision.mcp === true) mcpIds.push(id); } - // MCP registration follows consent, never a refresh of a pre-existing integration. + // MCP registration follows the separate MCP consent (explicit selection, + // a y after the MCP note, or a stored decision that recorded it) — never + // a refresh, an `integrate` decision or a y given without the note. await integrateMcp(tx, detected, mcpIds, (...args) => ui.detail(...args)); ui.step("Account Rotator"); console.log("\n Keep authorized logins as separate encrypted accounts."); @@ -335,7 +360,7 @@ async function cmdSetup() { } } if (choice === undefined) { - console.log(" Account Rotator setting unchanged (no interactive answer)."); + console.log(abortedByUser ? " Account Rotator question skipped (aborted); setting unchanged." : " Account Rotator setting unchanged (no interactive answer)."); console.log(" Enable later: zcode-kit accounts enable"); } else { const result = configureAccountRotator(ctx, tx, choice, runProxyCli); @@ -380,11 +405,18 @@ async function cmdSetup() { ui.warn(`connection details unavailable: the proxy manager could not be loaded (${err.message})`); } } - if (manager) await manager.printConnectionDetails(await manager.healthIdentify() === "ours" ? "running" : "configured"); - else console.log(" Connection details unavailable: no proxy manager in this kit layout (see zcode-kit proxy status after a full install)."); + if (manager) { + // Same rules as `proxy status`: verified own proxy → running; nothing on + // the port → configured values; a foreign service → details withheld + // (never the key next to a URL that is not ours). + const identity = await manager.healthIdentify(); + if (identity === "ours" || identity === "down") await manager.printConnectionDetails(identity === "ours" ? "running" : "configured"); + else if (identity === "foreign") console.log(" Connection details withheld: the configured port is answered by a service that is not this kit's proxy (see zcode-kit proxy status)."); + else console.log(` Connection details unavailable (${identity}); see zcode-kit proxy status.`); + } else console.log(" Connection details unavailable: no proxy manager in this kit layout (see zcode-kit proxy status after a full install)."); if (compact) console.log(`\n Setup log: ${ui.logPath}`); - if (abortedByUser) return 130; - return outcome.failed.length ? 1 : 0; + if (abortedByUser) return EXIT_ABORTED; + return outcome.failed.length ? EXIT_HARNESS_FAILED : 0; } function sameInstallationPath(candidate, expected) { @@ -490,8 +522,11 @@ async function cmdIntegrate() { console.log(`== integrate ${id}: ${adapter.label} ==`); adapter.apply(ctx, tx, (m) => console.log(m)); // An explicit integrate command is consent for this harness: later - // setup/update/repair runs keep it current without asking again. - recordHarnessChoice(ctx, tx, readHarnessChoices(ctx), id, "configured", "integrate"); + // setup/update/repair runs keep it current without asking again. It is + // not MCP consent (integrate never registers the bridge). + if (!recordHarnessChoice(ctx, tx, readHarnessChoices(ctx), id, "configured", "integrate")) { + console.log(`WARN: the decision for ${id} was not recorded — its decision file is unreadable; fix or remove it (see zcode-kit setup).`); + } } finally { let finishErr = null; try { txId = tx.finish(); } catch (err) { finishErr = err; } @@ -912,14 +947,19 @@ function finishUpdate(harnessArgs, txId = null) { stdio: "inherit", env: { ...process.env, ZCODE_KIT_ALLOW_CHECKOUT: "1" }, }); - // Exit 1 = one or more harnesses failed but the kit itself is fine: the - // proxy must still come back. Anything else is a real setup failure. - const harnessFailures = res.status === 1; - if (res.status !== 0 && !harnessFailures) { + // Exit 20 = one or more harnesses failed but the kit itself is fine; exit + // 130 = the user interrupted the questions (answers given were applied). + // In both cases the proxy must still come back. Anything else is a real + // setup failure. + const harnessFailures = res.status === EXIT_HARNESS_FAILED; + const aborted = res.status === EXIT_ABORTED; + const partialExit = harnessFailures ? EXIT_HARNESS_FAILED : aborted ? EXIT_ABORTED : 0; + if (res.status !== 0 && !harnessFailures && !aborted) { console.error("update: setup failed — the kit files are updated; inspect the output above and rerun `zcode-kit setup`."); return res.status ?? 2; } if (harnessFailures) console.error("update: some assistants could not be configured (see the summary above); the proxy is started anyway."); + if (aborted) console.error("update: setup was interrupted; the kit files are updated and the answers given so far were applied — finish with `zcode-kit setup`. The proxy is started anyway."); // update stopped the proxy before mutating files, so it must come back // here: an update that returns with a dead proxy is not done. The // manager `start` is idempotent (already-running exits 0) and safe-starts @@ -930,7 +970,7 @@ function finishUpdate(harnessArgs, txId = null) { if (!existsSync(managerPath)) { console.log("update: no proxy manager in this kit layout — skipping the proxy start."); if (txId) console.log(`transaction ${txId} recorded — undo with: zcode-kit rollback ${txId}`); - return harnessFailures ? 1 : 0; + return partialExit; } console.log("update: starting the proxy on the updated code..."); const started = spawnSync(process.execPath, [managerPath, "start"], { stdio: "inherit" }); @@ -939,7 +979,7 @@ function finishUpdate(harnessArgs, txId = null) { return started.status ?? 2; } if (txId) console.log(`transaction ${txId} recorded — undo with: zcode-kit rollback ${txId}`); - return harnessFailures ? 1 : 0; + return partialExit; } // ---------------------------------------------------------------- rollback diff --git a/docs/ACCOUNT_ROTATOR.de.md b/docs/ACCOUNT_ROTATOR.de.md index 7caa327..c5826a2 100644 --- a/docs/ACCOUNT_ROTATOR.de.md +++ b/docs/ACCOUNT_ROTATOR.de.md @@ -5,7 +5,9 @@ Der optionale Account Rotator verwaltet autorisierte Logins als getrennte Konten. Ist er aktiviert, wird ein erfolgreicher neuer Login als weiteres Konto gespeichert. Bei unterstützten Anfragen kann das Kit ein anderes gespeichertes Konto versuchen, wenn das ausgewählte Konto nicht fortfahren -kann. Ein erneuter Versuch ist nicht garantiert erfolgreich. +kann. Ein erneuter Versuch ist nicht garantiert erfolgreich. Vorübergehende +Netz- oder Gateway-Fehler vor jeder Ausgabe werden zuerst auf demselben Konto +wiederholt; ein solcher Wiederholungsversuch wechselt das Konto nie von sich aus. Die Funktion erstellt keine Konten, setzt keine Kontingente zurück und umgeht keine Provider-Regeln. Der Import eines Logins gewährt kein neues Kontingent. diff --git a/docs/ACCOUNT_ROTATOR.md b/docs/ACCOUNT_ROTATOR.md index 4306839..6298410 100644 --- a/docs/ACCOUNT_ROTATOR.md +++ b/docs/ACCOUNT_ROTATOR.md @@ -4,7 +4,9 @@ The optional Account Rotator keeps authorized sign-ins as separate accounts. While it is enabled, a successful new sign-in is saved as another account. For supported requests, the Kit may try another saved account if the selected one -cannot continue. A retry is not guaranteed to succeed. +cannot continue. A retry is not guaranteed to succeed. Transient network or +gateway failures before any output are retried on the same account first; such +a retry never switches the account by itself. The feature does not create accounts, reset quotas, or bypass provider rules. Importing a login does not grant a new quota. Use only accounts you are diff --git a/harnesses/README.de.md b/harnesses/README.de.md index d3b7223..ad1499b 100644 --- a/harnesses/README.de.md +++ b/harnesses/README.de.md @@ -20,7 +20,7 @@ Schlüssel im separaten Zustand dieser Installation außerhalb von `node_modules ## Einrichtung durch `zcode-kit setup` (erkannte Harnesses, nur mit Zustimmung) -`zcode-kit setup --harness auto` erkennt installierte Harnesses und fragt für jeden ohne gespeicherte Entscheidung: "Configure ZCode as a provider with its supported models in ? [y/n]". Ein `y` richtet diesen Harness ein; `n` überspringt ihn und lässt seine Dateien unberührt, und ohne Terminal wird jeder unentschiedene Harness übersprungen. Strg-C beendet die Fragen (Exit-Code 130); was du bereits mit `y` beantwortet hast, bleibt eingerichtet. Bei ausschließlich OMP werden keine Claude-/Codex-Konfigurationen oder Wrapper-Dateien erzeugt. Entscheidungen werden pro Harness als Datei unter `generated/harness-choices/` gespeichert und gehören zur Setup-Transaktion (ein Rollback entfernt sie wieder); `zcode-kit update` und `zcode-kit doctor --fix` wenden nur zugestimmte Integrationen erneut an (ein `y`, eine ausdrückliche Auswahl oder `zcode-kit integrate `), und ein gespeichertes `n` gilt, bis `--harness`, `integrate` oder `zcode-kit setup --reask` (fragt im Terminal erneut) es ändern. Eine Integration, die das Kit vor der Frage angelegt hat, wird bei unbeaufsichtigten Läufen aktualisiert, aber nie zur Zustimmung umgedeutet. Für unbeaufsichtigte Läufe wählst du Harnesses mit `--harness omp,codex` oder `ZCODE_KIT_HARNESSES=omp,codex` (`none` überspringt alle erkannten); unbekannte IDs sind Fehler. Die MCP-Registrierung folgt derselben Zustimmung. Ein fehlgeschlagener Harness stoppt die anderen nicht: Seine eigenen Teiländerungen werden zurückgenommen, die Zusammenfassung führt ihn als fehlgeschlagen, und das Setup endet mit Exit-Code 1 (der Installer fährt mit einer Warnung fort). +`zcode-kit setup --harness auto` erkennt installierte Harnesses und fragt für jeden ohne gespeicherte Entscheidung: "Configure ZCode as a provider with its supported models in ? [y/n]". Ein `y` richtet diesen Harness ein; `n` überspringt ihn und lässt seine Dateien unberührt, und ohne Terminal wird jeder unentschiedene Harness übersprungen. Strg-C beendet die Fragen (Exit-Code 130); was du bereits mit `y` beantwortet hast, bleibt eingerichtet. Bei ausschließlich OMP werden keine Claude-/Codex-Konfigurationen oder Wrapper-Dateien erzeugt. Entscheidungen werden pro Harness als Datei unter `generated/harness-choices/` gespeichert und gehören zur Setup-Transaktion (ein Rollback entfernt sie wieder); `zcode-kit update` und `zcode-kit doctor --fix` wenden nur zugestimmte Integrationen erneut an (ein `y`, eine ausdrückliche Auswahl oder `zcode-kit integrate `), und ein gespeichertes `n` gilt, bis `--harness`, `integrate` oder `zcode-kit setup --reask` (fragt im Terminal erneut) es ändern. Eine Integration, die das Kit vor der Frage angelegt hat, wird bei unbeaufsichtigten Läufen aktualisiert, aber nie zur Zustimmung umgedeutet. Für unbeaufsichtigte Läufe wählst du Harnesses mit `--harness omp,codex` oder `ZCODE_KIT_HARNESSES=omp,codex` (`none` überspringt alle erkannten); unbekannte IDs sind Fehler. Die MCP-Bridge des Kits wird nur mit gesonderter Zustimmung registriert: bei ausdrücklicher Auswahl oder nach einem `y`, dem das Setup vor der Frage den MCP-Hinweis vorangestellt hat (`--no-mcp` schaltet es ab); `integrate`, eine Auffrischung oder eine gespeicherte Entscheidung ohne diesen Hinweis registrieren sie nie. Ein fehlgeschlagener Harness stoppt die anderen nicht: Seine eigenen Teiländerungen werden zurückgenommen, die Zusammenfassung führt ihn als fehlgeschlagen, und das Setup endet mit Exit-Code 20 (die Installer fahren mit einer Warnung fort). | Harness | Mechanismus | Eingriff in bestehende Config | |---|---|---| @@ -105,5 +105,7 @@ Proxy-Prüfung belegt keinen Erfolg beim nativen Provider. Details: - `GET /quota` (authentifiziert) zeigt die Token-Buckets je Modell. - Kontingent erschöpft → HTTP 400 `[1005] exceed quota limit`. Der Proxy wiederholt denselben Account nach einem wachsenden Zeitplan (bis ~65s), bevor er weiterwechselt; tritt der Fehler weiter auf, warte, bis der Anbieter wieder Kontingent bereitstellt. - `[3007] captcha verify failed` → Gateway-Anti-Absicherung. Der Proxy wiederholt einmal mit einem frisch erzeugten Captcha-Token; schlägt das erneut fehl, lege eine Pause ein. +- Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 mit kurzem `Retry-After`) werden bis zu 3-mal auf demselben Konto mit wachsender Wartezeit wiederholt, wie es der offizielle Client tut. Ein erkannter Gateway-Fehlercode, ein Authentifizierungs- oder Modellfehler und alles nach begonnener Ausgabe werden nie wiederholt; ein `Retry-After` über 15 Sekunden wird an den Client durchgereicht. Einstellbar über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen; proxy start/restart reicht den Wert durch). +- Ein vom Upstream abgebrochener Stream endet mit einer Fehlermeldung statt einer stillen Kürzung: Chat-Streams mit `data: {"error":…}`, Responses-Streams mit `response.failed`, native Anthropic-Streams mit einem `event: error`-Frame (`upstream_incomplete` oder `upstream_stream_error`). Nach begonnener Ausgabe wird nichts wiederholt; sende den Turn aus dem Harness erneut. - `401 start_plan_jwt_invalid` → Desktop-Anmeldung prüfen und mit `zcode-kit auth login zai` erneuern. Mit `zcode-kit auth login zai --import` den aktuell aktiven `zai`/`start-plan`-Login aus Desktop 0.16.9 mit ausdrücklich konfiguriertem Plan importieren. Eine vorhandene `credentials.json` ist maßgeblich; bei ungültigen Anmeldedaten erfolgt kein stiller Rückgriff auf `config.json`. Moderne `coding-plan`-Logins verwenden stattdessen normales OAuth; der Import erstellt oder ermittelt keine API-Schlüssel. - `[1210]` bei Flash → prüfen, ob Thinking aktiviert ist, und `low`, `high` oder `max` wählen, statt Thinking zu deaktivieren. Der Proxy normalisiert deaktiviertes Thinking auf `low`; siehe den Flash-Hinweis oben. diff --git a/harnesses/README.es.md b/harnesses/README.es.md index 24a0211..80150d0 100644 --- a/harnesses/README.es.md +++ b/harnesses/README.es.md @@ -21,7 +21,7 @@ guarda fuera de `node_modules`, en el estado exclusivo de esa instalación. ## Configurado por `zcode-kit setup` (harnesses detectados, solo con consentimiento) -`zcode-kit setup --harness auto` detecta los harnesses instalados y pregunta, por cada uno sin decisión guardada, "Configure ZCode as a provider with its supported models in ? [y/n]". Un `y` configura ese harness; `n` lo omite y deja sus archivos intactos, y sin terminal se omite todo harness sin decidir. Ctrl-C detiene las preguntas (código de salida 130); lo que ya respondiste con `y` queda configurado. Si únicamente se detecta OMP, no se crean configuraciones ni wrappers de Claude/Codex. Las decisiones se guardan en un archivo por harness en `generated/harness-choices/` y forman parte de la transacción de configuración (un rollback las elimina de nuevo); `zcode-kit update` y `zcode-kit doctor --fix` solo vuelven a aplicar integraciones consentidas (un `y`, una selección explícita o `zcode-kit integrate `), y un `n` guardado se respeta hasta que `--harness`, `integrate` o `zcode-kit setup --reask` (vuelve a preguntar en una terminal) lo cambien. Una integración que el kit creó antes de preguntar se actualiza en ejecuciones desatendidas, pero nunca se convierte en consentimiento. Para ejecuciones desatendidas selecciona los harnesses con `--harness omp,codex` o `ZCODE_KIT_HARNESSES=omp,codex` (`none` omite todos los detectados); los id desconocidos son errores. El registro MCP sigue el mismo consentimiento. Un harness que falla no detiene a los demás: sus propios cambios parciales se deshacen, el resumen lo lista como fallido y la configuración termina con código 1 (el instalador continúa con una advertencia). +`zcode-kit setup --harness auto` detecta los harnesses instalados y pregunta, por cada uno sin decisión guardada, "Configure ZCode as a provider with its supported models in ? [y/n]". Un `y` configura ese harness; `n` lo omite y deja sus archivos intactos, y sin terminal se omite todo harness sin decidir. Ctrl-C detiene las preguntas (código de salida 130); lo que ya respondiste con `y` queda configurado. Si únicamente se detecta OMP, no se crean configuraciones ni wrappers de Claude/Codex. Las decisiones se guardan en un archivo por harness en `generated/harness-choices/` y forman parte de la transacción de configuración (un rollback las elimina de nuevo); `zcode-kit update` y `zcode-kit doctor --fix` solo vuelven a aplicar integraciones consentidas (un `y`, una selección explícita o `zcode-kit integrate `), y un `n` guardado se respeta hasta que `--harness`, `integrate` o `zcode-kit setup --reask` (vuelve a preguntar en una terminal) lo cambien. Una integración que el kit creó antes de preguntar se actualiza en ejecuciones desatendidas, pero nunca se convierte en consentimiento. Para ejecuciones desatendidas selecciona los harnesses con `--harness omp,codex` o `ZCODE_KIT_HARNESSES=omp,codex` (`none` omite todos los detectados); los id desconocidos son errores. El puente MCP del kit solo se registra con consentimiento aparte: una selección explícita o un `y` dado tras la nota MCP que la configuración muestra antes de la pregunta (`--no-mcp` lo desactiva); `integrate`, una actualización o una decisión guardada sin esa nota nunca lo registran. Un harness que falla no detiene a los demás: sus propios cambios parciales se deshacen, el resumen lo lista como fallido y la configuración termina con código 20 (los instaladores continúan con una advertencia). | Harness | Mecanismo | Impacto en la config existente | |---|---|---| @@ -106,5 +106,7 @@ nativo. Detalles: [puente MCP](../mcp/zcode-harness-mcp/README.es.md). - `GET /quota` (autenticado) muestra los buckets de tokens por modelo. - Cuota agotada → HTTP 400 `[1005] exceed quota limit`. El proxy reintenta la misma cuenta con un calendario creciente (hasta ~65s) antes de cambiar de cuenta; si aún lo ves, espera a que el proveedor restablezca la cuota. - `[3007] captcha verify failed` → anti-abuso del gateway. El proxy reintenta una vez con un token CAPTCHA recién emitido; si aún falla, haz una pausa. +- Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 con un `Retry-After` corto) se reintentan hasta 3 veces en la misma cuenta con una espera creciente, como hace el cliente oficial. Un código de error del gateway reconocido, un error de autenticación o de modelo y cualquier cosa tras el inicio de la salida nunca se reintentan; un `Retry-After` superior a 15 segundos se pasa al cliente. Ajústalo con `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones que nunca se establecieron; proxy start/restart lo transmite). +- Un flujo que el upstream corta termina con un mensaje de error en lugar de un truncamiento silencioso: los flujos de chat con `data: {"error":…}`, los de Responses con `response.failed`, los flujos Anthropic nativos con un frame `event: error` (`upstream_incomplete` o `upstream_stream_error`). Nada se repite una vez iniciada la salida; vuelve a enviar el turno desde el harness. - `401 start_plan_jwt_invalid` → comprueba la sesión de Desktop y renuévala con `zcode-kit auth login zai`. Usa `zcode-kit auth login zai --import` para el login activo de `zai`/`start-plan` en Desktop 0.16.9 con un plan configurado explícitamente. Si existe `credentials.json`, es la fuente autoritativa; unas credenciales inválidas no provocan una vuelta silenciosa a `config.json`. Los logins modernos de `coding-plan` usan el OAuth normal; la importación no crea ni obtiene claves API. - `[1210]` con Flash → comprueba que thinking esté activado y elige `low`, `high` o `max`, en lugar de desactivarlo. El proxy normaliza thinking desactivado a `low`; consulta la nota sobre Flash anterior. diff --git a/harnesses/README.ja.md b/harnesses/README.ja.md index bb27357..b9f252f 100644 --- a/harnesses/README.ja.md +++ b/harnesses/README.ja.md @@ -20,7 +20,7 @@ ## `zcode-kit setup` によるセットアップ(検出されたハーネス、同意がある場合のみ) -`zcode-kit setup --harness auto` はインストール済みのハーネスを検出し、保存済みの決定がないハーネスごとに "Configure ZCode as a provider with its supported models in ? [y/n]" と質問します。`y` でそのハーネスを設定し、`n` はスキップしてファイルに触れません。ターミナルがなければ未決定のハーネスはすべてスキップされます。Ctrl-C で質問を中止できます(終了コード 130)。すでに `y` と答えたものは設定されたままです。OMP しかない場合、Claude/Codex の設定や生成ラッパーは作られません。決定はハーネスごとに 1 ファイルとして `generated/harness-choices/` に保存され、セットアップのトランザクションの一部です(ロールバックで再び削除されます)。`zcode-kit update` と `zcode-kit doctor --fix` は同意済みの統合(`y`、明示的な選択、または `zcode-kit integrate `)だけを再適用し、保存された `n` は `--harness`、`integrate`、または `zcode-kit setup --reask`(ターミナルで再質問)で変えるまで尊重されます。質問より前に kit が作成した統合は無人実行でも更新されますが、同意と見なされることはありません。無人実行では `--harness omp,codex` または `ZCODE_KIT_HARNESSES=omp,codex` でハーネスを選択します(`none` は検出されたすべてをスキップ)。不明な id はエラーです。MCP の登録も同じ同意に従います。1 つのハーネスが失敗しても他は止まりません。そのハーネス自身の部分的な書き込みは元に戻され、サマリーに失敗として表示され、セットアップは終了コード 1 で終わります(インストーラーは警告を出して続行します)。 +`zcode-kit setup --harness auto` はインストール済みのハーネスを検出し、保存済みの決定がないハーネスごとに "Configure ZCode as a provider with its supported models in ? [y/n]" と質問します。`y` でそのハーネスを設定し、`n` はスキップしてファイルに触れません。ターミナルがなければ未決定のハーネスはすべてスキップされます。Ctrl-C で質問を中止できます(終了コード 130)。すでに `y` と答えたものは設定されたままです。OMP しかない場合、Claude/Codex の設定や生成ラッパーは作られません。決定はハーネスごとに 1 ファイルとして `generated/harness-choices/` に保存され、セットアップのトランザクションの一部です(ロールバックで再び削除されます)。`zcode-kit update` と `zcode-kit doctor --fix` は同意済みの統合(`y`、明示的な選択、または `zcode-kit integrate `)だけを再適用し、保存された `n` は `--harness`、`integrate`、または `zcode-kit setup --reask`(ターミナルで再質問)で変えるまで尊重されます。質問より前に kit が作成した統合は無人実行でも更新されますが、同意と見なされることはありません。無人実行では `--harness omp,codex` または `ZCODE_KIT_HARNESSES=omp,codex` でハーネスを選択します(`none` は検出されたすべてをスキップ)。不明な id はエラーです。kit の MCP ブリッジは別個の同意がある場合だけ登録されます。明示的な選択か、セットアップが質問の前に表示する MCP の注記の後に答えた `y` です(`--no-mcp` で無効化)。`integrate`、更新、その注記なしの保存済み決定では登録されません。1 つのハーネスが失敗しても他は止まりません。そのハーネス自身の部分的な書き込みは元に戻され、サマリーに失敗として表示され、セットアップは終了コード 20 で終わります(インストーラーは警告を出して続行します)。 | ハーネス | 仕組み | 既存 config への影響 | |---|---|---| @@ -106,5 +106,7 @@ Desktop が必要な場合がありますが、プロバイダーがモデル呼 - `GET /quota`(認証付き)はモデルごとのトークンバケットを表示します。 - クォータ消費済み → HTTP 400 `[1005] exceed quota limit`。プロキシは同じアカウントを成長間隔で再試行し(最大約65秒)、それから次のアカウントへ切り替えます。それでも表示される場合は、プロバイダーによる利用枠の回復を待ってください。 - `[3007] captcha verify failed` → ゲートウェイ側のアンチアビューズ。プロキシは新しく発行した CAPTCHA トークンで一度再試行します。それでも失敗する場合は、しばらく休憩してください。 +- 出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、短い `Retry-After` 付きの 429)は、公式クライアントと同様に同じアカウントで待ち時間を増やしながら最大 3 回再試行します。認識されたゲートウェイのエラーコード、認証やモデルのエラー、出力開始後の障害は再試行しません。15 秒を超える `Retry-After` はクライアントにそのまま渡します。`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で調整できます(基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します。proxy start/restart が値を引き継ぎます)。 +- アップストリームが途中で切断したストリームは、無言で途切れる代わりにエラーで終わります。チャットのストリームは `data: {"error":…}`、Responses は `response.failed`、ネイティブの Anthropic ストリームは 1 つの `event: error` フレーム(`upstream_incomplete` または `upstream_stream_error`)です。出力開始後は何も再送しません。ハーネスからターンを送り直してください。 - `401 start_plan_jwt_invalid` → Desktop のログインを確認し、`zcode-kit auth login zai` で更新。プランが明示的に設定された Desktop 0.16.9 の現在アクティブな `zai`/`start-plan` ログインには `zcode-kit auth login zai --import` を使います。`credentials.json` が存在する場合はそれが正となり、認証情報が無効でも `config.json` へ暗黙にはフォールバックしません。新形式の `coding-plan` ログインでは通常の OAuth を使います。インポートは API キーの作成や取得を行いません。 - Flash で `[1210]` → thinking が有効か確認し、無効にする代わりに `low`、`high`、`max` を選びます。プロキシは、無効にした thinking を `low` に正規化します。上記の Flash の注記を参照してください。 diff --git a/harnesses/README.md b/harnesses/README.md index cc6b511..8b71288 100644 --- a/harnesses/README.md +++ b/harnesses/README.md @@ -38,10 +38,13 @@ integrations (a `y`, an explicit selection or `zcode-kit integrate integration the kit created before it asked is refreshed on unattended runs but never turned into consent. For unattended runs select harnesses with `--harness omp,codex` or `ZCODE_KIT_HARNESSES=omp,codex` (`none` skips all -detected harnesses); unknown ids are errors. MCP registration follows the -same consent. One failing harness does not stop the others: its own partial -writes are undone, the summary lists it as failed and setup exits 1 (the -installer continues with a warning). +detected harnesses); unknown ids are errors. The kit's MCP bridge is +registered only with separate consent: an explicit selection, or a `y` given +after the MCP note that setup prints before the question (`--no-mcp` opts +out); `integrate`, a refresh or a stored decision without that note never +register it. One failing harness does not stop the others: its own partial +writes are undone, the summary lists it as failed and setup exits with code +20 (the installers continue with a warning). | Harness | Mechanism | Impact on existing config | |---|---|---| @@ -133,5 +136,7 @@ establish native-provider acceptance. Details: - `GET /quota` (authenticated) shows the token buckets per model. - Quota exhausted → HTTP 400 `[1005] exceed quota limit`. The proxy retries the same account on a growing schedule (up to ~65s) before failing over; if you still see it, wait for the provider to restore quota. - `[3007] captcha verify failed` → gateway anti-abuse. The proxy retries once with a freshly minted captcha token; if it still fails, take a pause. +- Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429 with a short `Retry-After`) are retried up to 3 times on the same account with a growing delay, mirroring the official client. A recognised gateway error code, an authentication or model error, and anything after output has started are never retried; a `Retry-After` above 15 seconds is passed to the client instead. Tune with `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (base delay in milliseconds, default 500, capped at 10000; `off` keeps only the retry of connections that were never established; proxy start/restart passes it through). +- A stream that the upstream cuts off ends with an error payload instead of a silent truncation: chat streams get `data: {"error":…}`, Responses streams `response.failed`, native Anthropic streams one `event: error` frame (`upstream_incomplete` or `upstream_stream_error`). Nothing is replayed after output has started; send the turn again from the harness. - `401 start_plan_jwt_invalid` → check your Desktop login and renew it with `zcode-kit auth login zai`. Use `zcode-kit auth login zai --import` for the current active Desktop 0.16.9 `zai`/`start-plan` login with an explicitly configured plan. Existing `credentials.json` is authoritative; invalid credentials do not silently fall back to `config.json`. Modern `coding-plan` logins use normal OAuth instead; import does not create or resolve API keys. - `[1210]` with Flash → check that thinking is enabled and choose `low`, `high`, or `max`, rather than disabling it. The proxy normalizes disabled thinking to `low`; see the Flash note above. diff --git a/harnesses/README.zh-CN.md b/harnesses/README.zh-CN.md index 99b64e0..7819c1f 100644 --- a/harnesses/README.zh-CN.md +++ b/harnesses/README.zh-CN.md @@ -19,7 +19,7 @@ kit 的核心与具体 harness 无关:一个提供三种标准格式的本地 ## 由 `zcode-kit setup` 配置(检测到的 harness,仅在同意后) -`zcode-kit setup --harness auto` 检测已安装的 harness,并对每个没有已保存决定的 harness 提问:"Configure ZCode as a provider with its supported models in ? [y/n]"。回答 `y` 才配置该 harness;`n` 会跳过它且不触碰其文件,没有终端时所有未决定的 harness 都会被跳过。按 Ctrl-C 会停止提问(退出码 130);已经回答 `y` 的仍保持配置。如果只装了 OMP,就不会创建 Claude/Codex 的配置或生成包装器文件。决定以每个 harness 一个文件的形式保存在 `generated/harness-choices/` 中,并属于设置事务的一部分(回滚会再次删除它们);`zcode-kit update` 和 `zcode-kit doctor --fix` 只重新应用已同意的集成(回答 `y`、明确选择或 `zcode-kit integrate `),已保存的 `n` 会一直被遵守,直到通过 `--harness`、`integrate` 或 `zcode-kit setup --reask`(在终端中重新提问)更改。Kit 在提问之前创建的集成会在无人值守运行时刷新,但绝不会被视为同意。无人值守运行时用 `--harness omp,codex` 或 `ZCODE_KIT_HARNESSES=omp,codex` 选择 harness(`none` 跳过所有检测到的 harness);未知 id 视为错误。MCP 注册遵循同样的同意。某个 harness 失败不会阻止其他 harness:它自身的部分写入会被撤销,汇总中将其列为失败,设置以退出码 1 结束(安装器会带着警告继续)。 +`zcode-kit setup --harness auto` 检测已安装的 harness,并对每个没有已保存决定的 harness 提问:"Configure ZCode as a provider with its supported models in ? [y/n]"。回答 `y` 才配置该 harness;`n` 会跳过它且不触碰其文件,没有终端时所有未决定的 harness 都会被跳过。按 Ctrl-C 会停止提问(退出码 130);已经回答 `y` 的仍保持配置。如果只装了 OMP,就不会创建 Claude/Codex 的配置或生成包装器文件。决定以每个 harness 一个文件的形式保存在 `generated/harness-choices/` 中,并属于设置事务的一部分(回滚会再次删除它们);`zcode-kit update` 和 `zcode-kit doctor --fix` 只重新应用已同意的集成(回答 `y`、明确选择或 `zcode-kit integrate `),已保存的 `n` 会一直被遵守,直到通过 `--harness`、`integrate` 或 `zcode-kit setup --reask`(在终端中重新提问)更改。Kit 在提问之前创建的集成会在无人值守运行时刷新,但绝不会被视为同意。无人值守运行时用 `--harness omp,codex` 或 `ZCODE_KIT_HARNESSES=omp,codex` 选择 harness(`none` 跳过所有检测到的 harness);未知 id 视为错误。Kit 的 MCP 桥接只有在单独同意后才会注册:明确选择,或在设置于提问前显示 MCP 提示后回答 `y`(`--no-mcp` 可关闭);`integrate`、刷新或没有该提示的已保存决定绝不会注册它。某个 harness 失败不会阻止其他 harness:它自身的部分写入会被撤销,汇总中将其列为失败,设置以退出码 20 结束(安装器会带着警告继续)。 | Harness | 机制 | 对现有配置的影响 | |---|---|---| @@ -102,5 +102,7 @@ GLM-5.3-Flash 已通过代理路径验证。原生目录中存在模型条目, - `GET /quota`(需认证)显示各模型的令牌桶。 - 配额耗尽 → HTTP 400 `[1005] exceed quota limit`。代理会按递增间隔重试同一账号(最长约65秒),然后切换账号;如果仍然出现,请等待服务商恢复额度。 - `[3007] captcha verify failed` → 网关反滥用机制。代理会使用新获取的 CAPTCHA 令牌自动重试一次;如果仍然失败,请暂停片刻。 +- 输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、带较短 `Retry-After` 的 429)会在同一账号上以递增等待最多重试 3 次,与官方客户端一致。已识别的网关错误码、认证或模型错误以及输出开始后的任何故障都不会重试;超过 15 秒的 `Retry-After` 会直接交给客户端处理。可用 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 调整(基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试;proxy start/restart 会传递该值)。 +- 被上游中断的流会以错误结束,而不是无声截断:聊天流为 `data: {"error":…}`,Responses 流为 `response.failed`,原生 Anthropic 流为一个 `event: error` 帧(`upstream_incomplete` 或 `upstream_stream_error`)。输出开始后不会重放任何内容;请从 harness 重新发送该轮。 - `401 start_plan_jwt_invalid` → 检查 Desktop 登录,并通过 `zcode-kit auth login zai` 更新。对于 Desktop 0.16.9 当前激活且已明确配置计划的 `zai`/`start-plan` 登录,使用 `zcode-kit auth login zai --import`。只要存在 `credentials.json`,就以它为准;凭据无效时不会静默回退到 `config.json`。新版 `coding-plan` 登录使用常规 OAuth;导入不会创建或获取 API 密钥。 - Flash 返回 `[1210]` → 检查是否已启用 thinking,并选择 `low`、`high` 或 `max`,而不是禁用它。代理会将禁用的 thinking 规范化为 `low`;参见上方 Flash 说明。 diff --git a/install.ps1 b/install.ps1 index d9e0563..80772a5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -170,9 +170,12 @@ $bunExe = @(Get-Command bun -CommandType Application -ErrorAction Stop)[0].Sourc # One y/n question per detected assistant, then the Account Rotator question, # read from the console; without a terminal, undecided assistants are skipped. node cli/zcode-kit.mjs setup --harness auto --installer - if ($LASTEXITCODE -eq 1) { + if ($LASTEXITCODE -eq 20) { # The kit is installed; one or more assistants failed (see the summary). Write-Host ' [WARN] some assistants could not be configured; see the summary above (the kit itself is installed)' + } elseif ($LASTEXITCODE -eq 130) { + # Ctrl-C during the questions; answers already given were applied. + Write-Host ' [WARN] setup was interrupted; the kit is installed and the answers given so far were applied. Finish later with: zcode-kit setup' } elseif ($LASTEXITCODE -ne 0) { throw "setup failed (exit $LASTEXITCODE) - see output above" } } finally { Pop-Location diff --git a/install.sh b/install.sh index ecd0533..f7c54e9 100644 --- a/install.sh +++ b/install.sh @@ -165,15 +165,20 @@ step "[4/4] Configuring your workspace" # instead; without one, undecided assistants are skipped, never configured. run_setup() { node cli/zcode-kit.mjs setup --harness auto --installer; } setup_status=0 -if [ -t 1 ] && [ -r /dev/tty ]; then +# `[ -r /dev/tty ]` only checks permissions; opening it proves a controlling +# terminal exists (no terminal: ENXIO). Without one, setup gets /dev/null so +# nothing can read the script pipe as an answer. +if [ -t 1 ] && (exec < /dev/tty) 2>/dev/null; then run_setup < /dev/tty || setup_status=$? else - run_setup || setup_status=$? + run_setup < /dev/null || setup_status=$? fi case "$setup_status" in 0) : ;; - # Exit 1: the kit is installed, one or more assistants failed (see the summary). - 1) printf ' [WARN] some assistants could not be configured; see the summary above (the kit itself is installed)\n' ;; + # Exit 20: the kit is installed, one or more assistants failed (see the summary). + 20) printf ' [WARN] some assistants could not be configured; see the summary above (the kit itself is installed)\n' ;; + # Exit 130: Ctrl-C during the questions; answers already given were applied. + 130) printf ' [WARN] setup was interrupted; the kit is installed and the answers given so far were applied. Finish later with: zcode-kit setup\n' ;; *) die "setup failed; see the diagnostics above" 1 ;; esac diff --git a/lib/proxy-env.mjs b/lib/proxy-env.mjs index 501a79b..5b6828d 100644 --- a/lib/proxy-env.mjs +++ b/lib/proxy-env.mjs @@ -10,7 +10,7 @@ export function proxyEnv(ctx, source = process.env) { if (name !== name.toUpperCase()) delete env[name]; continue; } - if ((/^ZCODE_(?:PROXY_|CLAIM_|ASYNC_|DUMP_|CAPTCHA_)/i.test(name) && !['ZCODE_PROXY_CREDENTIALS_PATH', 'ZCODE_PROXY_CREDENTIAL_SECRET', 'ZCODE_PROXY_CREDENTIAL_MASTER_KEY', 'ZCODE_PROXY_ACCOUNTS_PATH', 'ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS'].includes(name.toUpperCase())) || /^(?:CAPTCHA_|PE_PATCH$)/i.test(name)) delete env[name]; + if ((/^ZCODE_(?:PROXY_|CLAIM_|ASYNC_|DUMP_|CAPTCHA_)/i.test(name) && !['ZCODE_PROXY_CREDENTIALS_PATH', 'ZCODE_PROXY_CREDENTIAL_SECRET', 'ZCODE_PROXY_CREDENTIAL_MASTER_KEY', 'ZCODE_PROXY_ACCOUNTS_PATH', 'ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS', 'ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS'].includes(name.toUpperCase())) || /^(?:CAPTCHA_|PE_PATCH$)/i.test(name)) delete env[name]; } env.ZCODE_PROXY_CONFIG = ctx.config; env.ZCODE_IDENTITY_ENV_CWD = '/workspace'; diff --git a/lib/transaction.mjs b/lib/transaction.mjs index cfa791a..18798a1 100644 --- a/lib/transaction.mjs +++ b/lib/transaction.mjs @@ -157,6 +157,10 @@ export class Transaction { this.ops = []; this.dir = join(backupDir, `tx-${this.id}`); this.finishedId = undefined; + // Backup names carry a counter that only grows: restoreSince() shortens + // `ops` but keeps conflict ops with their backups, so an index derived + // from ops.length could be handed out twice and overwrite a kept backup. + this.backupSeq = 0; } /** Record a file the kit is about to write (existing → modify, new → create). */ @@ -165,7 +169,7 @@ export class Transaction { if (this.ops.some((op) => op.target === t)) return this; mkdirSync(this.dir, { recursive: true, mode: 0o700 }); if (existsSync(t)) { - const idx = this.ops.length; + const idx = this.backupSeq++; const backupName = `${String(idx).padStart(3, "0")}-${basename(t)}`; copyPrivate(t, join(this.dir, backupName)); this.ops.push({ kind: "modify", target: t, backup: backupName, preHash: sha256File(t) }); diff --git a/proxy/zcode-proxy-manager.mjs b/proxy/zcode-proxy-manager.mjs index 371a26c..540e1e3 100644 --- a/proxy/zcode-proxy-manager.mjs +++ b/proxy/zcode-proxy-manager.mjs @@ -887,18 +887,19 @@ try { */ async function connectionDetails(source) { let models = configuredModelIds(CONFIG); + let modelsSource = "config"; if (source === "running") { try { const res = await fetch(`${base()}/v1/models`, { headers: { Authorization: `Bearer ${readKey()}` }, signal: AbortSignal.timeout(5000) }); const j = await res.json(); const ids = (j?.data ?? []).map((m) => m?.id).filter((id) => typeof id === "string" && id.length > 0); - if (res.ok && ids.length) models = ids; + if (res.ok && ids.length) { models = ids; modelsSource = "live"; } } catch { - // config list stays + // config list stays, labelled as such } } const server = configuredServer(CONFIG); - return connectionDetailsLines({ port: loadPort(), key: readKey(), models, source, reveal: shouldRevealKey(), host: server.host, responsesEnabled: server.responsesEnabled }); + return connectionDetailsLines({ port: loadPort(), key: readKey(), models, modelsSource, source, reveal: shouldRevealKey(), host: server.host, responsesEnabled: server.responsesEnabled }); } async function printConnectionDetails(source) { diff --git a/tests/connection-details.test.mjs b/tests/connection-details.test.mjs index 683d599..67565ae 100644 --- a/tests/connection-details.test.mjs +++ b/tests/connection-details.test.mjs @@ -60,6 +60,18 @@ test("formatter: Responses route follows the config switch, a non-loopback host assert.doesNotMatch(localhost, /WARNING/); }); +test("formatter: a verified proxy says when the model list came from configuration; an IPv6 loopback listener keeps its URL", () => { + const fromConfig = connectionDetailsLines({ port: 1, key: KEY, source: "running", modelsSource: "config" }).join("\n"); + assert.match(fromConfig, /Model IDs:.*\(from configuration; live model list unavailable\)/); + const live = connectionDetailsLines({ port: 1, key: KEY, source: "running", modelsSource: "live" }).join("\n"); + assert.doesNotMatch(live, /live model list unavailable/); + const unverified = connectionDetailsLines({ port: 1, key: KEY, source: "configured", modelsSource: "config" }).join("\n"); + assert.doesNotMatch(unverified, /live model list unavailable/, "only a verified proxy has a live list to miss"); + const v6 = connectionDetailsLines({ port: 1, key: KEY, host: "::1" }).join("\n"); + assert.match(v6, /http:\/\/\[::1\]:1\/v1/); + assert.doesNotMatch(v6, /WARNING/); +}); + test("configuredServer reads host and the Responses switch, with template defaults when absent", (t) => { const dir = mkdtempSync(join(tmpdir(), "kit-conn-srv-")); t.after(() => rmSync(dir, { recursive: true, force: true })); diff --git a/tests/harness-consent.test.mjs b/tests/harness-consent.test.mjs index d5c216d..b384dc2 100644 --- a/tests/harness-consent.test.mjs +++ b/tests/harness-consent.test.mjs @@ -126,7 +126,14 @@ test("decideHarness: explicit lists win, stored decisions stand, answers decide, const flag = { ids: ["pi"], none: false, source: "flag" }; assert.equal((await decideHarness({ ...base, explicit: flag, ask: yes })).action, "ignore"); const selected = await decideHarness({ ...base, id: "pi", explicit: flag, detected: false, ask: yes }); - assert.deepEqual(selected, { action: "configure", source: "flag", reason: "selected via --harness", record: true, consent: true }); + assert.deepEqual(selected, { action: "configure", source: "flag", reason: "selected via --harness", record: true, consent: true, mcp: true }, "an explicit selection is documented to cover the MCP bridge"); + // MCP consent is separate from provider consent: a y covers the bridge only + // when the MCP note was shown before the question; stored decisions carry it. + assert.equal(y.mcp, false, "y without the MCP note is provider consent only"); + assert.equal((await decideHarness({ ...base, ask: yes, mcpOffered: true })).mcp, true); + assert.equal(stored.mcp, false, "a stored decision without the flag never implies MCP"); + assert.equal((await decideHarness({ ...base, stored: "configured", storedMcp: true, ask: count })).mcp, true); + assert.equal((await decideHarness({ ...base, owned: true, ask: null })).mcp, undefined, "a refresh never registers MCP"); const selectedOverSkip = await decideHarness({ ...base, id: "pi", stored: "skipped", explicit: flag, ask: null }); assert.equal(selectedOverSkip.action, "configure", "an explicit selection overrides a stored n"); const noneSel = { ids: [], none: true, source: "env" }; @@ -186,7 +193,10 @@ test("harness choices: one file per harness, malformed files fail closed and are writeFileSync(join(dir, "codex.json"), JSON.stringify({ schema: 1, decision: "maybe" })); writeFileSync(join(dir, "notes.txt"), "ignored"); const loaded = readHarnessChoices(f.ctx); - assert.deepEqual(loaded.harnesses, { omp: { decision: "configured", source: "flag", decidedAt: "2026-01-01T00:00:00.000Z" } }); + assert.deepEqual(loaded.harnesses, { omp: { decision: "configured", source: "flag", decidedAt: "2026-01-01T00:00:00.000Z", mcp: false } }, "a decision without the flag never implies MCP consent"); + writeFileSync(join(dir, "aider.json"), JSON.stringify({ schema: 1, harness: "aider", decision: "configured", source: "interactive", decidedAt: "", mcp: true })); + assert.equal(readHarnessChoices(f.ctx).harnesses.aider.mcp, true); + rmSync(join(dir, "aider.json")); assert.deepEqual(loaded.unreadable.sort(), ["codex", "pi"], "malformed and unknown decisions count as unreadable, never as consent"); assert.equal(loaded.errors.length, 2); assert.ok(loaded.errors.every((e) => /fix or remove the file/.test(e))); @@ -298,6 +308,16 @@ test("transaction savepoint/restoreSince undoes only the ops of one failed harne assert.equal(readFileSync(foreign, "utf8"), "changed by someone else\n", "a file changed meanwhile is left alone"); assert.equal(readFileSync(kept, "utf8"), "first harness\n", "ops before the savepoint stay applied"); assert.deepEqual(tx.ops.map((op) => op.kind), ["modify", "modify", "external"], "conflicts and externals stay journalled"); + // A later touch of a file with the same basename must not reuse the kept + // conflict op's backup name (its backup would be overwritten). + const conflictBackup = join(tx.dir, tx.ops[1].backup); + assert.equal(readFileSync(conflictBackup, "utf8"), "original\n"); + mkdirSync(join(f.root, "sub")); + const sameName = join(f.root, "sub", "foreign.txt"); + writeFileSync(sameName, "second original\n"); + commitFile(ctx, tx, sameName, "kit write two\n"); + assert.notEqual(tx.ops[tx.ops.length - 1].backup, tx.ops[1].backup, "backup names never collide after restoreSince"); + assert.equal(readFileSync(conflictBackup, "utf8"), "original\n", "the kept conflict backup is intact"); const id = tx.finish(); const rolled = rollbackTransaction(f.backupDir, id); assert.equal(readFileSync(kept, "utf8"), "before\n", "the finished transaction still rolls back the kept op"); @@ -478,7 +498,7 @@ test("one failing harness does not stop the others: its partial writes are undon const f = fixture(t); writeFileSync(f.pi, "{ this is not json"); const r = await run(f, ["setup", "--harness", "omp,pi"]); - assert.equal(r.code, 1, r.text); + assert.equal(r.code, 20, `exit 20 = kit configured, a harness failed (never 1, which a crash also yields)\n${r.text}`); assert.match(r.stdout, /\[OK\]\s+OMP \/ Oh My Pi/); assert.match(r.stdout, /\[FAIL\] pi — .*not valid JSON/); assert.match(r.stdout, /Assistants: 1 configured, 0 skipped, 1 failed/); @@ -514,6 +534,23 @@ test("no detected harness is not an error; integrate records consent; doctor rep assert.equal(readFileSync(g.omp, "utf8"), ompBefore, "doctor --fix never integrates a skipped harness"); }); +test("integrate records provider consent only: a later setup refreshes the harness but never registers MCP from it", async (t) => { + const f = fixture(t); + const r1 = await run(f, ["integrate", "omp"]); + assert.equal(r1.code, 0, r1.text); + assert.deepEqual(choicesOf(f), { omp: "configured/integrate" }); + assert.equal(existsSync(f.mcp), false, "integrate never registers the bridge"); + const r2 = await run(f, ["setup"]); + assert.equal(r2.code, 0, r2.text); + assert.match(r2.stdout, /\[OK\]\s+OMP \/ Oh My Pi\s*$/m, "stored consent refreshes the provider integration"); + assert.equal(existsSync(f.mcp), false, "a stored decision without MCP consent never registers the bridge"); + assert.equal(JSON.parse(readFileSync(f.choice("omp"), "utf8")).mcp, undefined, "the flag is not invented later"); + const r3 = await run(f, ["setup", "--harness", "omp"]); + assert.equal(r3.code, 0, r3.text); + assert.equal(existsSync(f.mcp), true, "an explicit selection is documented MCP consent"); + assert.equal(JSON.parse(readFileSync(f.choice("omp"), "utf8")).mcp, true); +}); + test("rolling back a setup removes the decisions it recorded together with the integration", async (t) => { const f = fixture(t); const r = await run(f, ["setup", "--harness", "omp"]); @@ -530,6 +567,9 @@ test("rolling back a setup removes the decisions it recorded together with the i // order, y configures, n skips, the rotator question follows separately, and // Ctrl-C stops the questions without touching what was not consented to. const script = process.platform === "linux" && spawnSync("script", ["--version"], { stdio: "ignore" }).status === 0; +// The interactive proofs must not vanish silently from CI: Linux runners +// ship util-linux, so a missing `script` there is an environment error. +if (process.platform === "linux" && process.env.CI && !script) throw new Error("script(1) is required on Linux CI for the pty tests"); /** * Run the CLI on a pty. `input` is either a string typed ahead before the * first question, or [{ after, send }] pairs typed once `after` appeared in diff --git a/zcode-proxy-src/README.de.md b/zcode-proxy-src/README.de.md index 6ea584a..3901be8 100644 --- a/zcode-proxy-src/README.de.md +++ b/zcode-proxy-src/README.de.md @@ -56,6 +56,8 @@ nur Metadaten. Die lokale Korrektur dekodiert gzip, deflate und Brotli vor der Auswertung übersetzter Streams oder JSON-Fehlerantworten. Leere oder nicht dekodierbare Batch-Antworten werden als `upstream_invalid_response` gemeldet, nicht als erfolgreiche leere Antworten. Der Proxy ersetzt das Arbeitsverzeichnis des aufrufenden Harness nicht durch sein eigenes; `ZCODE_IDENTITY_ENV_CWD` bleibt ein ausdrücklicher Override. +Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 mit kurzem `Retry-After`) werden wie beim offiziellen Client bis zu dreimal auf demselben Konto mit wachsender Wartezeit wiederholt; erkannte Gateway-Fehlercodes, Authentifizierungs- oder Modellfehler und alles nach begonnener Ausgabe werden nie wiederholt. Ein nativer Anthropic-Stream, der ohne `message_stop` endet, erhält einen abschließenden `event: error`-Frame (`upstream_incomplete`, bei fehlgeschlagenem Lesen `upstream_stream_error`), damit Clients den Fehler statt einer stillen Kürzung sehen. + ## Sicherheit Der Proxy ist für den vertrauenswürdigen lokalen Einsatz gedacht. Betreibe ihn diff --git a/zcode-proxy-src/README.es.md b/zcode-proxy-src/README.es.md index 88ab1f9..5479d84 100644 --- a/zcode-proxy-src/README.es.md +++ b/zcode-proxy-src/README.es.md @@ -56,6 +56,8 @@ depuración contienen únicamente metadatos. La corrección local decodifica gzip, deflate y Brotli antes de interpretar los flujos traducidos o las respuestas de error JSON. Los cuerpos vacíos o imposibles de decodificar se notifican como `upstream_invalid_response`, no como respuestas vacías correctas. El proxy no sustituye el directorio del asistente por el de su proceso; `ZCODE_IDENTITY_ENV_CWD` sigue siendo una anulación explícita. +Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 con un `Retry-After` corto) se reintentan hasta tres veces en la misma cuenta con una espera creciente, como hace el cliente oficial; los códigos de error del gateway reconocidos, los errores de autenticación o de modelo y cualquier cosa tras el inicio de la salida nunca se reintentan. Un flujo Anthropic nativo que termina sin `message_stop` recibe un frame final `event: error` (`upstream_incomplete`, o `upstream_stream_error` si falló la lectura) para que los clientes vean el fallo en lugar de un truncamiento silencioso. + ## Seguridad El proxy está pensado para un uso local de confianza. Mantenlo en el equipo diff --git a/zcode-proxy-src/README.ja.md b/zcode-proxy-src/README.ja.md index d8ed18e..5f9d59a 100644 --- a/zcode-proxy-src/README.ja.md +++ b/zcode-proxy-src/README.ja.md @@ -52,6 +52,8 @@ CAPTCHA 成功を証明するものでもありません。従来の診断用バ ローカル修正では、変換対象のストリームや JSON エラー応答を解釈する前に gzip、deflate、Brotli を展開します。空または展開できないバッチ応答は、空の成功応答ではなく `upstream_invalid_response` として通知します。プロキシは自身の作業ディレクトリを呼び出し元のワークスペースとして提示しません。`ZCODE_IDENTITY_ENV_CWD` は明示的な上書きとして維持されます。 +出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、短い `Retry-After` 付きの 429)は、公式クライアントと同様に同じアカウントで待ち時間を増やしながら最大 3 回再試行します。認識されたゲートウェイのエラーコード、認証やモデルのエラー、出力開始後の障害は再試行しません。`message_stop` なしで終わったネイティブの Anthropic ストリームには、無言の途切れではなく障害が分かるよう、最後に 1 つの `event: error` フレーム(`upstream_incomplete`、読み取り失敗時は `upstream_stream_error`)を付加します。 + ## セキュリティ このプロキシは、信頼できるローカル環境での使用を想定しています。 diff --git a/zcode-proxy-src/README.md b/zcode-proxy-src/README.md index 02b984b..b6a253e 100644 --- a/zcode-proxy-src/README.md +++ b/zcode-proxy-src/README.md @@ -50,6 +50,8 @@ Do not use those switches. The solver, its security gates, and the callable The local repair decodes gzip, deflate, and Brotli before interpreting translated streams or JSON error envelopes. Empty or undecodable batch bodies are reported as `upstream_invalid_response`, not successful empty answers. The proxy does not substitute its daemon directory for the calling harness workspace; `ZCODE_IDENTITY_ENV_CWD` remains an explicit override. +Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429 with a short `Retry-After`) are retried up to three times on the same account with a growing delay, as the official client does; recognised gateway error codes, authentication or model errors, and anything after output has started are never retried (`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` sets the base delay in milliseconds, default 500; `off` keeps only the retry of never-established connections). A native Anthropic stream that ends without `message_stop` receives one terminal `event: error` frame (`upstream_incomplete`, or `upstream_stream_error` when the read failed) so clients see the failure instead of a silent truncation. + ## Security The proxy is intended for trusted local use. Keep it on the local machine and diff --git a/zcode-proxy-src/README.zh-CN.md b/zcode-proxy-src/README.zh-CN.md index b918b69..a7c13cb 100644 --- a/zcode-proxy-src/README.zh-CN.md +++ b/zcode-proxy-src/README.zh-CN.md @@ -43,6 +43,8 @@ node /zcode-proxy-src/captcha-compatibility.mjs [re 本地修复在解析转换流或 JSON 错误响应前解码 gzip、deflate 和 Brotli。空的或无法解码的批量响应会报告为 `upstream_invalid_response`,不会视为成功的空回复。代理不会将自身进程的目录冒充为调用方工作目录;`ZCODE_IDENTITY_ENV_CWD` 仍可用于显式覆盖。 +输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、带较短 `Retry-After` 的 429)会与官方客户端一样在同一账号上以递增等待最多重试三次;已识别的网关错误码、认证或模型错误以及输出开始后的任何故障都不会重试。没有 `message_stop` 就结束的原生 Anthropic 流会收到一个终止的 `event: error` 帧(`upstream_incomplete`,读取失败时为 `upstream_stream_error`),让客户端看到故障而不是无声截断。 + ## 安全 该代理面向可信的本地环境。请仅在本机运行,并保护登录信息和配置数据。 diff --git a/zcode-proxy-src/src/auth/account-rotator.test.ts b/zcode-proxy-src/src/auth/account-rotator.test.ts index c802714..7f01df6 100644 --- a/zcode-proxy-src/src/auth/account-rotator.test.ts +++ b/zcode-proxy-src/src/auth/account-rotator.test.ts @@ -109,6 +109,18 @@ describe("account rotator", () => { expect(rotator.getSelectedId()).toBe("key-only"); }); + it("list() marks the inference account active even right after a lookup served by another profile", () => { + const rotator = createAccountRotator([ + { id: "key-only", credential: { provider: "zai", apiKey: "key-a" } }, + { id: "with-jwt", credential: { provider: "zai", apiKey: "key-b", jwt: "jwt-b" } }, + ]); + expect(rotator.getCredentialHandle().id).toBe("key-only"); + expect(rotator.getCredentialHandle({ operation: "quota" }).id).toBe("with-jwt"); + const states = Object.fromEntries(rotator.list().map((a) => [a.id, a.state])); + expect(states).toEqual({ "key-only": "active", "with-jwt": "ready" }); + expect(rotator.getCredentialHandle({ operation: "inference" }).id).toBe("key-only"); + }); + it("treats the same wire token as one identity regardless of userId metadata", () => { const rotator = createAccountRotator([ { id: "imported", credential: { provider: "zai", apiKey: "k", jwt: "jwt-same" } }, diff --git a/zcode-proxy-src/src/auth/account-rotator.ts b/zcode-proxy-src/src/auth/account-rotator.ts index 5703b2c..411a483 100644 --- a/zcode-proxy-src/src/auth/account-rotator.ts +++ b/zcode-proxy-src/src/auth/account-rotator.ts @@ -293,9 +293,10 @@ export class AccountRotator { ? candidates.find((candidate) => candidate.id === this.activeAccountId) : undefined; const account = active ?? candidates[0]; - // Only inference moves the sticky pointer. Billing/quota/async lookups - // require a JWT the active account may lack; serving them from another - // profile must not switch the identity that later inference requests use. + // Only inference moves the sticky pointer. Billing/quota lookups and + // async job submissions require a JWT the active account may lack; + // serving them from another profile must not switch the identity that + // later inference requests use. const inference = options.operation === undefined || options.operation === "inference"; if (inference) this.activeAccountId = account.id; account.lastUsedAt = now; @@ -459,7 +460,10 @@ export class AccountRotator { else if (!this.isPlanCompatible(account)) state = "invalid"; else if (isExpired(credential, now)) state = "expired"; else if (account.exhaustedUntil !== undefined && account.exhaustedUntil > now) state = "exhausted"; - else if (account.id === this.lastSelectedId) state = "active"; + // "active" is the sticky inference account; a lookup served by another + // profile must not move the marker (it falls back to the last selection + // only while no inference account exists yet). + else if (account.id === (this.activeAccountId ?? this.lastSelectedId)) state = "active"; const preview = maskCredential(credential); return { id: account.id, diff --git a/zcode-proxy-src/src/proxy/captcha-retry.test.ts b/zcode-proxy-src/src/proxy/captcha-retry.test.ts index c95f36e..12247c4 100644 --- a/zcode-proxy-src/src/proxy/captcha-retry.test.ts +++ b/zcode-proxy-src/src/proxy/captcha-retry.test.ts @@ -91,6 +91,45 @@ function fakeCaptcha(opts?: { failSolve?: boolean }): CaptchaModuleLike { } describe("retryOnCaptchaChallenge", () => { + it("does not resend when the client aborted while the fresh token was being solved", async () => { + const controller = new AbortController(); + let resends = 0; + const captcha: CaptchaModuleLike = { + ...fakeCaptcha(), + getCaptchaToken: async () => { + controller.abort(); // the client disappears mid-solve + return { verifyParam: "fresh-token", region: "cn" }; + }, + }; + const outcome = await retryOnCaptchaChallenge({ + captcha, + appVersion: "3.11.2", + challengedResp: new Response(bytesBody(challengeBytes()), { status: 400, headers: { "content-type": "application/json" } }), + signal: controller.signal, + solveAndRetry: async () => { resends += 1; return new Response("must not happen", { status: 200 }); }, + mapError: (err, phase) => new Response(`${phase}:${err.message}`, { status: 502 }), + }); + expect(resends).toBe(0); + expect(outcome.ok).toBe(false); + expect(await outcome.resp.text()).toMatch(/^dispatch:client aborted/); + }); + + it("does not even solve when the client had already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + let solves = 0; + const captcha: CaptchaModuleLike = { ...fakeCaptcha(), getCaptchaToken: async () => { solves += 1; return { verifyParam: "t", region: "cn" }; } }; + const outcome = await retryOnCaptchaChallenge({ + captcha, appVersion: "3.11.2", + challengedResp: new Response(bytesBody(challengeBytes()), { status: 400, headers: { "content-type": "application/json" } }), + signal: controller.signal, + solveAndRetry: async () => new Response("must not happen"), + mapError: (err, phase) => new Response(`${phase}:${err.message}`, { status: 502 }), + }); + expect(solves).toBe(0); + expect(outcome.ok).toBe(false); + }); + it("cancels the challenged body, re-solves, and re-dispatches once with fresh headers", async () => { let cancelled = false; const challenged = new Response(bytesBody(challengeBytes()), { diff --git a/zcode-proxy-src/src/proxy/captcha-retry.ts b/zcode-proxy-src/src/proxy/captcha-retry.ts index 27933a6..b91ed42 100644 --- a/zcode-proxy-src/src/proxy/captcha-retry.ts +++ b/zcode-proxy-src/src/proxy/captcha-retry.ts @@ -81,6 +81,12 @@ export interface CaptchaRetryArgs { mapError: (err: Error, phase: "solver" | "dispatch") => Response; /** Optional debug line sink. */ debug?: (message: string) => void; + /** + * Client abort signal: a client that disappeared while the fresh token was + * being solved must not trigger the resend (it would spend a token and + * quota on a request nobody reads). + */ + signal?: AbortSignal; } /** @@ -94,19 +100,23 @@ export interface CaptchaRetryArgs { * upstream `!ok` branch would wrap the error body a second time. */ export async function retryOnCaptchaChallenge(args: CaptchaRetryArgs): Promise<{ ok: true; resp: Response } | { ok: false; resp: Response }> { - const { captcha, appVersion, challengedResp, solveAndRetry, mapError, debug } = args; + const { captcha, appVersion, challengedResp, solveAndRetry, mapError, debug, signal } = args; debug?.("captcha challenge — re-solving and retrying once"); try { await challengedResp.body?.cancel(); } catch { // already drained/cancelled } + if (signal?.aborted) return { ok: false, resp: mapError(new Error("client aborted before the captcha retry"), "dispatch") }; let fresh: { verifyParam: string; region: string }; try { fresh = await captcha.getCaptchaToken(appVersion); } catch (err) { return { ok: false, resp: mapError(err as Error, "solver") }; } + // Re-check after the solve: it can take seconds, and a resend for a gone + // client is a wasted token and a replay nobody asked for. + if (signal?.aborted) return { ok: false, resp: mapError(new Error("client aborted before the captcha retry"), "dispatch") }; const retryHeaders: Record = { [captcha.RETRY_HEADERS.PARAM]: fresh.verifyParam, [captcha.RETRY_HEADERS.REGION]: fresh.region, diff --git a/zcode-proxy-src/src/proxy/credential-recovery.test.ts b/zcode-proxy-src/src/proxy/credential-recovery.test.ts index baa7586..0ae6c90 100644 --- a/zcode-proxy-src/src/proxy/credential-recovery.test.ts +++ b/zcode-proxy-src/src/proxy/credential-recovery.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, test } from "bun:test"; import { AuthManager } from "../auth/manager.js"; -import { proxyRequest } from "./handler.js"; +import { MAX_TRANSIENT_ATTEMPTS, proxyRequest } from "./handler.js"; import { handleResponses } from "./responses-handler.js"; import type { ProxyConfig } from "../config/types.js"; import { gzipSync } from "node:zlib"; @@ -11,9 +11,16 @@ import { fixtureSecret } from "../test-fixtures.js"; // handler-level tests pin. Pool tests exercise the multi-attempt schedule. const previousRetryDelaysEnv = process.env.ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS; process.env.ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS = "0"; +// The pre-output transient ladder (429/5xx, drops) would otherwise wait +// 1s/2s/4s (or a short Retry-After) between its attempts; unit 0 keeps the +// attempts and drops the waits. +const previousTransientUnitEnv = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; +process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "0"; afterAll(() => { if (previousRetryDelaysEnv === undefined) delete process.env.ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS; else process.env.ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS = previousRetryDelaysEnv; + if (previousTransientUnitEnv === undefined) delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + else process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = previousTransientUnitEnv; }); const OLD_KEY = fixtureSecret("recovery-old"); @@ -48,7 +55,10 @@ for (const route of ["openai", "anthropic", "responses"]) describe(`${route} saf expect(await resp.text()).not.toContain("PRIVATE_FIXTURE"); expect(resp.headers.get("set-cookie")).toBeNull(); if (status === 429) expect(resp.headers.get("retry-after")).toBe("7"); - expect(calls).toBe(1); + // 429/500/503 without a recognised gateway envelope are transient for + // the pre-output ladder: retried on the same credential until the budget + // is spent, then surfaced unchanged. 400/401/403 are request verdicts. + expect(calls).toBe(status === 429 || status >= 500 ? MAX_TRANSIENT_ATTEMPTS : 1); }); for (const code of [3012, 401, 3001]) test(`retries code ${code} once with changed credential before output`, async () => { let imports = 0; diff --git a/zcode-proxy-src/src/proxy/dump.ts b/zcode-proxy-src/src/proxy/dump.ts index 79e99a0..061c501 100644 --- a/zcode-proxy-src/src/proxy/dump.ts +++ b/zcode-proxy-src/src/proxy/dump.ts @@ -35,8 +35,12 @@ if (DUMP_PATH) { ); } -/** Header names whose values must be masked before dumping. */ -const SENSITIVE_HEADERS = new Set([ +/** + * Header names whose values must be masked before dumping or debug-logging + * (shared with handler.ts so both outputs mask the same set: credentials, + * cookies and the single-use captcha verify tokens of the start-plan gateway). + */ +export const SENSITIVE_HEADERS = new Set([ "authorization", "x-api-key", "proxy-authorization", diff --git a/zcode-proxy-src/src/proxy/handler-resilience.test.ts b/zcode-proxy-src/src/proxy/handler-resilience.test.ts index 308d956..4d15efb 100644 --- a/zcode-proxy-src/src/proxy/handler-resilience.test.ts +++ b/zcode-proxy-src/src/proxy/handler-resilience.test.ts @@ -83,18 +83,40 @@ describe("dispatchWithConnectRetry — replay safety (C1-02)", () => { } }); - it("does not retry an unknown fetch failure that may have happened after the POST body was sent", async () => { - let calls = 0; - const unknownReset = Object.assign(new TypeError("fetch failed"), { - cause: { code: "ECONNRESET" }, - }); - - await expect(dispatchWithConnectRetry(async () => { - calls += 1; - throw unknownReset; - }, { retryDelayMs: 0 })).rejects.toBe(unknownReset); + it("retries a connection drop before any response (reset/pipe/timeout), like the official client", async () => { + // Transient extension: a reset before response headers is retried on the + // same account — the request may have reached the gateway, which is the + // exposure the official ZCode client accepts for this class too. + for (const dropped of [ + Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }), + Object.assign(new Error("write EPIPE"), { code: "EPIPE" }), + Object.assign(new TypeError("fetch failed"), { cause: { code: "UND_ERR_SOCKET" } }), + new Error("socket hang up"), + ]) { + let calls = 0; + const response = await dispatchWithConnectRetry(async () => { + calls += 1; + if (calls === 1) throw dropped; + return new Response("ok"); + }, { retryDelayMs: 0 }); + expect(response.status).toBe(200); + expect(calls).toBe(2); + } + }); - expect(calls).toBe(1); + it("does not retry an unknown failure, a TLS failure or an abort", async () => { + for (const fatal of [ + new Error("something unrelated"), + Object.assign(new Error("certificate has expired"), { code: "CERT_HAS_EXPIRED" }), + Object.assign(new Error("aborted"), { name: "AbortError" }), + ]) { + let calls = 0; + await expect(dispatchWithConnectRetry(async () => { + calls += 1; + throw fatal; + }, { retryDelayMs: 0 })).rejects.toBe(fatal); + expect(calls).toBe(1); + } }); it("never retries an allowlisted error flagged postWrite", async () => { @@ -171,6 +193,32 @@ describe("proxyRequest — start-plan resilience (PR #34 review P1/P3)", () => { expect(body.choices[0].message.content).toBe("resilience reply"); }); + it("retries a gateway 503 before any output on the SAME credential and without touching the quota layer", async () => { + const authHeaders: (string | null)[] = []; + let calls = 0; + const fetchMock = mock(async (req: Request): Promise => { + calls += 1; + authHeaders.push(req.headers.get("authorization")); + if (calls === 1) return new Response("bad gateway", { status: 503, headers: { "content-type": "text/html" } }); + return new Response(ANTHROPIC_OK, { status: 200, headers: { "content-type": "application/json" } }); + }); + + const auth = new AuthManager(); + auth.setOAuthCredential({ apiKey: PLAN_KEY, provider: "zai", jwt: PLAN_JWT }); + const clientReq = new Request("http://localhost:8080/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"glm-4.6","messages":[{"role":"user","content":"hi"}]}', + }); + + const resp = await proxyRequest(clientReq, "openai", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any }); + expect(calls).toBe(2); + expect(authHeaders[0]).toBe(authHeaders[1]); // same account, fresh request + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.choices[0].message.content).toBe("resilience reply"); + }); + it("retries a connect failure with a FRESH Request (identity-pinned)", async () => { // Upstream: first call = connect-level failure (the bug shape), second // call = success. Review follow-up #1 (PR #34): a mock fetch never diff --git a/zcode-proxy-src/src/proxy/handler.ts b/zcode-proxy-src/src/proxy/handler.ts index 31469fb..9e92f1c 100644 --- a/zcode-proxy-src/src/proxy/handler.ts +++ b/zcode-proxy-src/src/proxy/handler.ts @@ -22,13 +22,13 @@ import { buildUpstreamHeaderPairs, buildUpstreamRequest, type UpstreamHeaderPair import { getDefaultEndpointRouting, type EndpointRoutingService } from "./endpoint-routing.js"; import { getDefaultClientSigning, sendWithClientSigning, type ClientSigningManager } from "./client-signing.js"; import { credentialString, type Credential } from "../auth/types.js"; -import { sendOrderedUpstreamRequest } from "./ordered-transport.js"; +import { isPostWriteError, sendOrderedUpstreamRequest } from "./ordered-transport.js"; import { transformRequestBody } from "./body-transformer.js"; import { isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; import { type ClientSessionResult } from "./client-session.js"; import { resolveSessionContext } from "./session-context.js"; import { gzipSync } from "node:zlib"; -import { recoverAndMapUpstream, parseGatewayErrorEnvelope } from "./upstream-errors.js"; +import { recoverAndMapUpstream, parseGatewayErrorEnvelope, inspectGatewayEnvelope } from "./upstream-errors.js"; export { parseGatewayErrorEnvelope } from "./upstream-errors.js"; // captcha.ts is loaded lazily inside the `startPlan` branch (only path that @@ -46,7 +46,8 @@ import { translateRequestOpenAIToAnthropic, translateResponseAnthropicToOpenAI } import { translateRequestAnthropicToOpenAI, translateResponseOpenAIToAnthropic } from "../translator/anthropic-to-openai.js"; import { anthropicSseToOpenaiSse, openaiSseToAnthropicSse } from "../translator/sse-translator.js"; import type { OpenAIChatRequest, OpenAIChatResponse, AnthropicMessagesRequest, AnthropicMessagesResponse } from "../translator/types.js"; -import { dumpPhase, dumpHeaders, dumpBody, dumpEnabled } from "./dump.js"; +import { dumpPhase, dumpHeaders, dumpBody, dumpEnabled, SENSITIVE_HEADERS } from "./dump.js"; +import { ensureAnthropicSseTerminal } from "./sse-terminal.js"; import { inflateWithCap, decodeContentStream } from "./inflate.js"; import { buildAnthropicMetadataUserId } from "./trace-headers.js"; @@ -290,9 +291,10 @@ export async function proxyRequest( }, { isAborted: () => clientReq.signal.aborted, - onRetry: (attempt, err) => { - if (debug) debugError(reqId, "upstream_connect_retry", `attempt ${attempt}/${MAX_CONNECT_ATTEMPTS - 1} failed (${err.message}), retrying in ${500 * attempt}ms`); - console.log(`${reqId} upstream connect failed (${err.message}), retry ${attempt + 1}/${MAX_CONNECT_ATTEMPTS} in ${500 * attempt}ms`); + signal: clientReq.signal, + onRetry: (attempt, reason, delayMs) => { + if (debug) debugError(reqId, "upstream_transient_retry", `attempt ${attempt}/${MAX_TRANSIENT_ATTEMPTS} failed (${reason}), retrying in ${delayMs}ms`); + console.log(`${reqId} upstream transient failure (${reason}), retry ${attempt + 1}/${MAX_TRANSIENT_ATTEMPTS} in ${delayMs}ms`); }, }, ); @@ -332,6 +334,7 @@ export async function proxyRequest( captcha, appVersion: config.identity.appVersion, challengedResp: upstreamResp, + signal: clientReq.signal, debug: debug ? (message) => debugLine(reqId, message) : undefined, solveAndRetry: (retryHeaders) => { console.log(`${reqId} captcha re-solved (token ${retryHeaders[captcha.RETRY_HEADERS.PARAM].length} chars), retrying...`); @@ -420,7 +423,10 @@ export async function proxyRequest( if (isSSE && upstreamResp.body) { const [clientBody, statsBody] = upstreamResp.body.tee(); observeStream(reqId, format, meta, upstreamResp.status, started, statsBody, upstreamResp.headers.get("content-encoding")); - return passthroughResponse(upstreamResp, clientAcceptsGzip(clientReq), clientBody); + // Native Anthropic streams get a terminal `event: error` when the upstream + // ends without message_stop or the read fails (only where the bytes are + // readable: an uncompressed stream, or one this proxy decompresses). + return passthroughResponse(upstreamResp, clientAcceptsGzip(clientReq), clientBody, ensureAnthropicSseTerminal); } if (upstreamResp.status === 200) { @@ -489,43 +495,183 @@ export function shouldUseOrderedTransport(config: ProxyConfig, clientSession: Cl return clientSession?.action === "enforce" || clientSession?.source === "explicit"; } -/** Max attempts (initial + 2 retries) for transient CONNECT-level failures. */ -export const MAX_CONNECT_ATTEMPTS = 3; +/** Max attempts (initial + 3 retries) of the pre-output transient ladder. */ +export const MAX_TRANSIENT_ATTEMPTS = 4; +const DEFAULT_TRANSIENT_RETRY_UNIT_MS = 500; +const MAX_TRANSIENT_RETRY_UNIT_MS = 10_000; +let transientRetryEnvWarned = false; /** - * Connect-level retry ladder shared by the chat hot path and /v1/responses. - * Only explicit pre-connect failure codes allow replay of a non-idempotent POST. + * Operator knob for the transient ladder, mirroring ZCODE_PROXY_QUOTA_RETRY_DELAYS_MS: + * ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS is the base unit of the backoff in + * decimal milliseconds (default 500, capped at 10000; 0 retries without + * waiting); "off" restores the connect-only ladder (never-connected failures + * only, no status or drop retries). Invalid values fall back to the default + * with a one-time warning. Exported for tests. + */ +export function transientRetryPolicy(override?: number): { unitMs: number; extended: boolean } { + if (override !== undefined) return { unitMs: override, extended: true }; + const raw = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + if (raw === undefined) return { unitMs: DEFAULT_TRANSIENT_RETRY_UNIT_MS, extended: true }; + const trimmed = raw.trim(); + if (trimmed.toLowerCase() === "off") return { unitMs: DEFAULT_TRANSIENT_RETRY_UNIT_MS, extended: false }; + if (!/^[0-9]+$/.test(trimmed)) { + if (!transientRetryEnvWarned) { + transientRetryEnvWarned = true; + console.error(`[transient-retry] ignoring invalid ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS=${JSON.stringify(raw)} — using the default`); + } + return { unitMs: DEFAULT_TRANSIENT_RETRY_UNIT_MS, extended: true }; + } + return { unitMs: Math.min(Number(trimmed), MAX_TRANSIENT_RETRY_UNIT_MS), extended: true }; +} +/** @deprecated alias kept for older callers; the ladder is sized by MAX_TRANSIENT_ATTEMPTS. */ +export const MAX_CONNECT_ATTEMPTS = MAX_TRANSIENT_ATTEMPTS; +/** A numeric Retry-After above this is surfaced to the client instead of waited out. */ +export const TRANSIENT_RETRY_AFTER_CAP_MS = 15_000; + +/** Failures that prove the connection was never established. */ +const CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "ConnectionRefused"]); +/** + * Connection dropped before any response bytes arrived. The official ZCode + * client retries this class (network failure) with backoff; the request may + * have reached the gateway, which is the same exposure that client accepts. + */ +const DROP_ERROR_CODES = new Set([ + "ECONNRESET", "EPIPE", "ETIMEDOUT", "ECONNABORTED", "EHOSTUNREACH", "ENETUNREACH", + "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "ConnectionClosed", +]); +const DROP_ERROR_MESSAGES = [/socket hang up/i, /other side closed/i, /connection closed/i, /connection reset/i]; +/** TLS/certificate failures are configuration problems, never transient. */ +const NON_TRANSIENT_CODE = /^(ERR_TLS_|ERR_SSL|CERT_|UNABLE_TO_|DEPTH_ZERO_|SELF_SIGNED)/; +/** Gateway/edge statuses the official client retries as server errors. */ +const TRANSIENT_STATUSES = new Set([500, 502, 503, 504, 524, 529]); + +type TransientKind = "connect" | "drop" | "status"; + +function errorChain(err: unknown): Array<{ code?: unknown; name?: unknown; message?: unknown; cause?: unknown }> { + const chain: Array<{ code?: unknown; name?: unknown; message?: unknown; cause?: unknown }> = []; + let cause: unknown = err; + for (let depth = 0; depth < 5 && cause !== null && typeof cause === "object"; depth += 1) { + const detail = cause as { code?: unknown; name?: unknown; message?: unknown; cause?: unknown }; + chain.push(detail); + cause = detail.cause; + } + return chain; +} + +/** Classify a thrown dispatch error; null = not retried (unknown, postWrite, abort, TLS). */ +export function transientErrorKind(err: unknown): { kind: TransientKind; reason: string } | null { + if (isPostWriteError(err)) return null; + const chain = errorChain(err); + if (chain.some((e) => e.name === "AbortError" || e.code === "ABORT_ERR")) return null; + for (const e of chain) { + if (typeof e.code !== "string") continue; + if (NON_TRANSIENT_CODE.test(e.code)) return null; + if (CONNECT_ERROR_CODES.has(e.code)) return { kind: "connect", reason: e.code }; + if (DROP_ERROR_CODES.has(e.code)) return { kind: "drop", reason: e.code }; + } + const text = chain.map((e) => (typeof e.message === "string" ? e.message : "")).join(" | "); + if (DROP_ERROR_MESSAGES.some((re) => re.test(text))) return { kind: "drop", reason: "connection dropped" }; + return null; +} + +function retryAfterMs(resp: Response): number | null { + const raw = resp.headers.get("retry-after"); + if (!raw || !/^\d{1,6}$/.test(raw.trim())) return null; + return Number(raw.trim()) * 1000; +} + +/** + * Classify an upstream response; null = returned to the recovery layers as + * is. A recognised gateway business envelope (quota, auth, model, captcha) is + * a verdict about this request or account, never a transient fault, and + * streams are never inspected. + */ +async function transientResponseReason(resp: Response): Promise { + if (resp.status !== 429 && !TRANSIENT_STATUSES.has(resp.status)) return null; + if (resp.headers.get("content-type")?.includes("text/event-stream")) return null; + const envelope = await inspectGatewayEnvelope(resp); + if (envelope) return null; + return `HTTP ${resp.status}`; +} + +function transientDelayMs(kind: TransientKind, attempt: number, unitMs: number): number { + if (kind === "connect") return unitMs * attempt; // never connected: cheap, quick + // Dropped connections and gateway errors: 2x growth with up to 25% jitter, + // the shape the official client uses (base 2s, factor 2) at a proxy scale. + const base = unitMs * 2 * 2 ** (attempt - 1); + return Math.round(base * (1 + Math.random() * 0.25)); +} + +async function abortableWait(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0 || signal?.aborted) return; + await new Promise((resolve) => { + const done = (): void => { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }; + const timer = setTimeout(done, ms); + signal?.addEventListener("abort", done, { once: true }); + }); +} + +/** + * Pre-output transient retry ladder shared by the chat hot path and + * /v1/responses. It runs strictly before any response byte reaches the + * client and re-dispatches the identical request on the SAME account: + * - thrown connect failures (never connected) and connection drops before + * a response (reset, pipe, timeout) that are not flagged `postWrite`; + * - HTTP 500/502/503/504/524/529 and 429 whose body is not a recognised + * gateway business envelope; a numeric Retry-After is honoured up to + * TRANSIENT_RETRY_AFTER_CAP_MS, above it the response is surfaced. + * Never retried: business envelopes (quota, auth, model, captcha — their own + * layers handle them), request validation errors, TLS/certificate failures, + * anything after the client aborted, and errors flagged `postWrite`. * - * Contract (review P1/P2, PR #34/#35): + * Contract (review P1/P2, PR #34/#35; transient extension after the + * "Continue fixes it" report): * - `attemptDispatch` must dispatch a FRESH request each call — a reused * Request has its body stream marked used after the first fetch. * - failures flagged `postWrite` (ordered transport already wrote the full * request) are never retried — the upstream may have processed it. - * - no retry once the client aborted (`opts.isAborted`). + * - no retry once the client aborted (`opts.isAborted` / `opts.signal`). + * - the ladder never touches the sticky account, the quota-retry memo or + * the captcha layer: those run on the response it returns. */ export async function dispatchWithConnectRetry( attemptDispatch: () => Promise, - opts: { isAborted?: () => boolean; onRetry?: (attempt: number, err: Error) => void; retryDelayMs?: number } = {}, + opts: { + isAborted?: () => boolean; + signal?: AbortSignal; + onRetry?: (attempt: number, reason: string, delayMs: number) => void; + /** Test seam: base unit of the backoff (default 500 ms; 0 disables waiting). */ + retryDelayMs?: number; + } = {}, ): Promise { + const { unitMs, extended } = transientRetryPolicy(opts.retryDelayMs); + const aborted = (): boolean => opts.isAborted?.() === true || opts.signal?.aborted === true; for (let attempt = 1; ; attempt++) { - if (opts.isAborted?.()) throw new Error("client aborted before upstream connect"); + if (aborted()) throw new Error("client aborted before upstream connect"); + let resp: Response; try { - return await attemptDispatch(); + resp = await attemptDispatch(); } catch (err) { - const chain: Array<{ code?: unknown; postWrite?: unknown; cause?: unknown }> = []; - let cause: unknown = err; - for (let depth = 0; depth < 5 && cause !== null && typeof cause === "object"; depth += 1) { - const detail = cause as { code?: unknown; postWrite?: unknown; cause?: unknown }; - chain.push(detail); - cause = detail.cause; - } - const connectCodes = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]); - if (chain.some(e => e.postWrite) || !chain.some(e => typeof e.code === "string" && connectCodes.has(e.code))) throw err; - if (attempt >= MAX_CONNECT_ATTEMPTS || opts.isAborted?.()) throw err; - const backoffMs = (opts.retryDelayMs ?? 500) * attempt; - opts.onRetry?.(attempt, err as Error); - await new Promise((r) => setTimeout(r, backoffMs)); + const transient = transientErrorKind(err); + if (!transient || (!extended && transient.kind !== "connect") || attempt >= MAX_TRANSIENT_ATTEMPTS || aborted()) throw err; + const delayMs = transientDelayMs(transient.kind, attempt, unitMs); + opts.onRetry?.(attempt, transient.reason, delayMs); + await abortableWait(delayMs, opts.signal); + continue; } + if (!extended || attempt >= MAX_TRANSIENT_ATTEMPTS || aborted()) return resp; + const reason = await transientResponseReason(resp); + if (reason === null) return resp; + const retryAfter = retryAfterMs(resp); + if (retryAfter !== null && retryAfter > TRANSIENT_RETRY_AFTER_CAP_MS) return resp; + // Unit 0 means "retry without waiting" (tests, operators): it also skips a + // short Retry-After; the cap above still surfaces a long one. + const delayMs = unitMs === 0 ? 0 : retryAfter !== null ? retryAfter : transientDelayMs("status", attempt, unitMs); + void resp.body?.cancel().catch(() => {}); + opts.onRetry?.(attempt, reason, delayMs); + await abortableWait(delayMs, opts.signal); + if (aborted()) throw new Error("client aborted before upstream retry"); } } @@ -684,6 +830,8 @@ function passthroughResponse( upstream: Response, clientAcceptsGzip: boolean, body?: ReadableStream, + /** Applied to the body bytes the client will read (after any decompression here). */ + monitor?: (body: ReadableStream) => ReadableStream, ): Response { const headers = new Headers(); const forwardHeaders = [ @@ -711,14 +859,17 @@ function passthroughResponse( const decompressed = source.pipeThrough(gunzip); headers.delete("content-encoding"); headers.delete("content-length"); - return new Response(decompressed, { + return new Response(monitor ? monitor(decompressed) : decompressed, { status: upstream.status, statusText: upstream.statusText, headers, }); } - return new Response(source, { + // A compressed body forwarded as-is cannot be inspected; the monitor only + // sees plain bytes. + const clientBody = source && monitor && !upstreamEncoding ? monitor(source) : source; + return new Response(clientBody, { status: upstream.status, statusText: upstream.statusText, headers, @@ -935,9 +1086,9 @@ function nextReqId(): string { } const DEBUG_BODY_PREVIEW = 200; -// Captcha verify tokens are single-use bearer material for the start-plan -// gateway: debug output masks them like credentials. -const SENSITIVE_HEADERS = new Set(["authorization", "x-api-key", "cookie", "set-cookie", "proxy-authorization", "x-aliyun-captcha-verify-param"]); +// Credentials, cookies and the single-use captcha verify tokens of the +// start-plan gateway are masked in debug output; the set is shared with the +// dump module so both outputs agree. function debugLine(reqId: string, msg: string): void { console.log(`${reqId} debug: ${msg}`); diff --git a/zcode-proxy-src/src/proxy/ordered-transport.test.ts b/zcode-proxy-src/src/proxy/ordered-transport.test.ts index 1c72a3b..d226302 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.test.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect } from "bun:test"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; -import { sendOrderedUpstreamRequest } from "./ordered-transport.js"; +import { isPostWriteError, sendOrderedUpstreamRequest, tlsServerName } from "./ordered-transport.js"; import { proxyRequest } from "./handler.js"; import { AuthManager } from "../auth/manager.js"; import type { ProxyConfig, ProxyIdentity } from "../config/types.js"; @@ -137,6 +137,44 @@ describe("sendOrderedUpstreamRequest — abort propagation", () => { } }); + it("flags an upstream close before response headers as postWrite (never replayed by any layer)", async () => { + // The request head and body are on the wire before `end` can arrive, so + // the failure must carry the postWrite mark the retry and failover layers + // refuse — the upstream may have processed the request. + const { createServer: createTcpServer } = await import("node:net"); + let requests = 0; + const tcp = createTcpServer((socket) => { socket.once("data", () => { requests += 1; socket.end(); }); }); + await new Promise((r) => tcp.listen(0, "127.0.0.1", r)); + try { + const port = (tcp.address() as AddressInfo).port; + let caught: unknown; + try { + await sendOrderedUpstreamRequest({ + url: `http://127.0.0.1:${port}/v1/messages`, + method: "POST", + headers: [["content-type", "application/json"]], + body: "{}", + }); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toMatch(/closed before sending response headers/); + expect((caught as { postWrite?: boolean }).postWrite).toBe(true); + expect(isPostWriteError(caught)).toBe(true); + expect(isPostWriteError(new Error("wrapped", { cause: caught }))).toBe(true); + expect(requests).toBe(1); + } finally { + tcp.close(); + } + }); + + it("tlsServerName: host names only; IPv4 and IPv6 literals (bracketed or bare) get no SNI", () => { + expect(tlsServerName("api.example.com")).toBe("api.example.com"); + expect(tlsServerName("127.0.0.1")).toBeUndefined(); + expect(tlsServerName("[::1]")).toBeUndefined(); + expect(tlsServerName("::1")).toBeUndefined(); + }); + it("pre-aborted signal: rejects before anything reaches the wire", async () => { const s = await startSilentServer(); try { diff --git a/zcode-proxy-src/src/proxy/ordered-transport.ts b/zcode-proxy-src/src/proxy/ordered-transport.ts index ee7b7ef..fb54524 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.ts @@ -163,7 +163,11 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P socket.once("error", fail); socket.once("end", () => { if (!responseStarted) { - reject(new Error("upstream closed before sending response headers")); + // The request head and body are on the wire by the time `end` can + // arrive (writes below are synchronous), so route it through fail(): + // the error carries `postWrite` and no retry or failover layer may + // resend it — the upstream may have processed the request already. + fail(new Error("upstream closed before sending response headers")); return; } finish(); @@ -188,10 +192,13 @@ function openSocket(url: URL, signal?: AbortSignal): Promise { } const port = Number(url.port || (isHttps ? 443 : 80)); if (signal?.aborted) return Promise.reject(new Error("client aborted before the upstream connection was opened")); + const host = connectHost(url.hostname); return new Promise((resolve, reject) => { const onAbort = () => { - socket.off("error", onError); + // Keep `onError` attached: a late "error" from the torn-down connect or + // handshake must land on a listener (an unhandled socket error would + // crash the process); after settling, the listener is a no-op. socket.destroy(); reject(new Error("client aborted during the upstream connection setup")); }; @@ -204,17 +211,45 @@ function openSocket(url: URL, signal?: AbortSignal): Promise { signal?.removeEventListener("abort", onAbort); resolve(socket); }; - // SNI carries host names only; Bun rejects an IP literal where Node - // silently drops it, so send it only for a real host name. - const servername = isIP(url.hostname) === 0 ? url.hostname : undefined; + const servername = tlsServerName(url.hostname); const socket: WireSocket = isHttps - ? connectTls({ host: url.hostname, port, ...(servername ? { servername } : {}) }, onConnect) - : connectTcp({ host: url.hostname, port }, onConnect); + ? connectTls({ host, port, ...(servername ? { servername } : {}) }, onConnect) + : connectTcp({ host, port }, onConnect); socket.once("error", onError); signal?.addEventListener("abort", onAbort, { once: true }); }); } +/** `URL.hostname` keeps the brackets of an IPv6 literal; the socket API wants the bare address. */ +function connectHost(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +/** + * SNI carries host names only; Bun rejects an IP literal where Node silently + * drops it, so a server name is sent only for a real host name (IPv4 and + * bracketed or bare IPv6 literals yield undefined). + */ +export function tlsServerName(hostname: string): string | undefined { + const bare = connectHost(hostname); + return isIP(bare) === 0 ? bare : undefined; +} + +/** + * True when the error (or any nested `cause`, up to five levels) was raised + * after the full request had been written to the upstream: such a failure + * must never be replayed by a retry or failover layer. + */ +export function isPostWriteError(err: unknown): boolean { + let cause: unknown = err; + for (let depth = 0; depth < 5 && cause !== null && typeof cause === "object"; depth += 1) { + const detail = cause as { postWrite?: unknown; cause?: unknown }; + if (detail.postWrite === true) return true; + cause = detail.cause; + } + return false; +} + function buildRequestHead(url: URL, method: string, headers: OrderedHeaderPair[], contentLength: number): string { const path = `${url.pathname || "/"}${url.search}`; const lines = [ diff --git a/zcode-proxy-src/src/proxy/responses-handler.test.ts b/zcode-proxy-src/src/proxy/responses-handler.test.ts index bce04d8..a3928cc 100644 --- a/zcode-proxy-src/src/proxy/responses-handler.test.ts +++ b/zcode-proxy-src/src/proxy/responses-handler.test.ts @@ -419,7 +419,7 @@ describe("handleResponses resilience (CL-08)", () => { const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { config: CONFIG, auth, fetchImpl }); expect(resp.status).toBe(502); - expect(calls).toBe(3); + expect(calls).toBe(4); // MAX_TRANSIENT_ATTEMPTS: initial + 3 retries const body = await resp.json(); expect(body.error.type).toBe("upstream_unreachable"); }); diff --git a/zcode-proxy-src/src/proxy/responses-handler.ts b/zcode-proxy-src/src/proxy/responses-handler.ts index 7d23ccf..4b016c1 100644 --- a/zcode-proxy-src/src/proxy/responses-handler.ts +++ b/zcode-proxy-src/src/proxy/responses-handler.ts @@ -258,11 +258,15 @@ export async function handleResponses( } } try { - // Connect-retry ladder mirrors the chat hot path (handler.ts): 3 attempts, - // fresh Request per dispatch (built inside `dispatch`), 500ms×attempt - // backoff, no retry once the client aborted. + // Pre-output transient ladder shared with the chat hot path (handler.ts): + // fresh Request per dispatch (built inside `dispatch`), bounded backoff, + // same account, no retry once the client aborted. upstreamResp = await dispatchWithConnectRetry(() => dispatch(upstreamHeaders), { isAborted: () => clientReq.signal.aborted, + signal: clientReq.signal, + onRetry: (attempt, reason, delayMs) => { + console.log(`[responses] upstream transient failure (${reason}), retry ${attempt + 1} in ${delayMs}ms`); + }, }); } catch (err) { return errorResponse(502, "upstream_unreachable", "Upstream request could not be completed."); @@ -280,6 +284,7 @@ export async function handleResponses( captcha, appVersion: opts.config.identity.appVersion, challengedResp: upstreamResp, + signal: clientReq.signal, debug: debug ? (message) => console.log(`[responses] ${message}`) : undefined, solveAndRetry: (retryHeaders) => dispatch( buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, opts.config.identity, opts.config.plan, retryHeaders, undefined), diff --git a/zcode-proxy-src/src/proxy/sse-terminal.test.ts b/zcode-proxy-src/src/proxy/sse-terminal.test.ts new file mode 100644 index 0000000..603b3ea --- /dev/null +++ b/zcode-proxy-src/src/proxy/sse-terminal.test.ts @@ -0,0 +1,93 @@ +/** + * Terminal-event guarantee for natively passed-through Anthropic streams: + * bytes are forwarded unchanged; exactly one `event: error` frame is appended + * when the upstream ends without message_stop (or the read fails), never a + * fabricated message_stop, never a second terminal event. + */ +import { describe, it, expect } from "bun:test"; +import { ANTHROPIC_INCOMPLETE_EVENT, ANTHROPIC_STREAM_ERROR_EVENT, ensureAnthropicSseTerminal } from "./sse-terminal.js"; + +const encoder = new TextEncoder(); +const START = 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n'; +const DELTA = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}\n\n'; +const STOP = 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; +const UPSTREAM_ERROR = 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n'; + +function streamOf(chunks: string[], opts: { failAfter?: boolean; onCancel?: () => void } = {}): ReadableStream { + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++])); + return; + } + if (opts.failAfter) controller.error(new Error("upstream socket reset")); + else controller.close(); + }, + cancel() { opts.onCancel?.(); }, + }); +} + +async function collect(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return out + decoder.decode(); + out += decoder.decode(value, { stream: true }); + } +} + +describe("ensureAnthropicSseTerminal", () => { + it("forwards a complete stream unchanged", async () => { + expect(await collect(ensureAnthropicSseTerminal(streamOf([START, DELTA, STOP])))).toBe(START + DELTA + STOP); + }); + + it("appends exactly one terminal error frame when the stream ends before message_stop", async () => { + const text = await collect(ensureAnthropicSseTerminal(streamOf([START, DELTA]))); + expect(text).toBe(START + DELTA + ANTHROPIC_INCOMPLETE_EVENT); + expect(text).not.toContain("event: message_stop"); // no fabricated stop event + }); + + it("leaves a stream that already ended with an upstream error event alone", async () => { + expect(await collect(ensureAnthropicSseTerminal(streamOf([START, UPSTREAM_ERROR])))).toBe(START + UPSTREAM_ERROR); + }); + + it("detects a terminal event line split across chunks", async () => { + const text = await collect(ensureAnthropicSseTerminal(streamOf([START, "event: message_", "stop\ndata: {\"type\":\"message_stop\"}\n\n"]))); + expect(text).toBe(START + STOP); + }); + + it("turns a read failure into one well-formed error frame and a clean end (no fabricated stop)", async () => { + const text = await collect(ensureAnthropicSseTerminal(streamOf([START, DELTA], { failAfter: true }))); + expect(text).toBe(START + DELTA + ANTHROPIC_STREAM_ERROR_EVENT); + const frames = text.trim().split("\n\n"); + expect(frames.filter((f) => f.startsWith("event: error")).length).toBe(1); + expect(text).not.toContain("event: message_stop"); + }); + + it("an empty upstream body still gets a terminal error frame", async () => { + expect(await collect(ensureAnthropicSseTerminal(streamOf([])))).toBe(ANTHROPIC_INCOMPLETE_EVENT); + }); + + it("propagates a client cancel to the source without appending anything", async () => { + let cancelled = false; + const monitored = ensureAnthropicSseTerminal(streamOf([START, DELTA], { onCancel: () => { cancelled = true; } })); + const reader = monitored.getReader(); + await reader.read(); + await reader.cancel("client went away"); + expect(cancelled).toBe(true); + }); + + it("the error frames are valid Anthropic error events", () => { + for (const frame of [ANTHROPIC_INCOMPLETE_EVENT, ANTHROPIC_STREAM_ERROR_EVENT]) { + const [eventLine, dataLine] = frame.trim().split("\n"); + expect(eventLine).toBe("event: error"); + const data = JSON.parse(dataLine.replace(/^data: /, "")); + expect(data.type).toBe("error"); + expect(typeof data.error.type).toBe("string"); + expect(typeof data.error.message).toBe("string"); + } + }); +}); diff --git a/zcode-proxy-src/src/proxy/sse-terminal.ts b/zcode-proxy-src/src/proxy/sse-terminal.ts new file mode 100644 index 0000000..6148980 --- /dev/null +++ b/zcode-proxy-src/src/proxy/sse-terminal.ts @@ -0,0 +1,67 @@ +/** + * Terminal-event guarantee for Anthropic Messages streams that are passed + * through natively (no translation). The gateway sometimes closes a stream + * without `message_stop`, or the upstream read fails mid-stream. Without a + * terminal event, Anthropic-format clients (OMP, Claude Code) see a silently + * truncated turn; with the documented `event: error` frame they can report + * or recover from the failure. Bytes already forwarded are never replayed + * and no `message_stop` is ever fabricated — only one well-formed error + * frame is appended when the stream ends without any terminal event. + */ + +export const ANTHROPIC_INCOMPLETE_EVENT = + 'event: error\ndata: {"type":"error","error":{"type":"upstream_incomplete","message":"Upstream stream ended before message_stop"}}\n\n'; +export const ANTHROPIC_STREAM_ERROR_EVENT = + 'event: error\ndata: {"type":"error","error":{"type":"upstream_stream_error","message":"Upstream stream failed before completion"}}\n\n'; + +const TERMINAL_EVENT_LINE = /^event:[ \t]*(message_stop|error)[ \t]*$/m; +const TAIL_KEEP = 128; + +/** + * Forward `source` unchanged and append one terminal `event: error` frame if + * the stream ends (EOF or read failure) before a `message_stop` or `error` + * event was seen. A client cancel is propagated to the source. + */ +export function ensureAnthropicSseTerminal(source: ReadableStream): ReadableStream { + const reader = source.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let tail = ""; + let terminalSeen = false; + let cancelled = false; + + const observe = (chunk: Uint8Array): void => { + if (terminalSeen) return; + const text = tail + decoder.decode(chunk, { stream: true }); + if (TERMINAL_EVENT_LINE.test(text)) terminalSeen = true; + tail = text.length > TAIL_KEEP ? text.slice(-TAIL_KEEP) : text; + }; + + return new ReadableStream({ + async pull(controller) { + let result: Awaited>; + try { + result = await reader.read(); + } catch { + if (!cancelled) { + if (!terminalSeen) controller.enqueue(encoder.encode(ANTHROPIC_STREAM_ERROR_EVENT)); + controller.close(); + } + return; + } + if (result.done) { + if (!cancelled) { + if (!terminalSeen) controller.enqueue(encoder.encode(ANTHROPIC_INCOMPLETE_EVENT)); + controller.close(); + } + return; + } + observe(result.value); + controller.enqueue(result.value); + }, + cancel(reason) { + cancelled = true; + return reader.cancel(reason).catch(() => {}); + }, + }); +} diff --git a/zcode-proxy-src/src/proxy/transient-retry.test.ts b/zcode-proxy-src/src/proxy/transient-retry.test.ts new file mode 100644 index 0000000..9036c63 --- /dev/null +++ b/zcode-proxy-src/src/proxy/transient-retry.test.ts @@ -0,0 +1,184 @@ +/** + * Pre-output transient retry ladder (dispatchWithConnectRetry): which + * failures are re-dispatched before any byte reaches the client, which are + * handed to the recovery layers untouched, and the bounds (attempts, + * Retry-After cap, client abort). + */ +import { describe, it, expect } from "bun:test"; +import { + dispatchWithConnectRetry, MAX_TRANSIENT_ATTEMPTS, TRANSIENT_RETRY_AFTER_CAP_MS, transientErrorKind, transientRetryPolicy, +} from "./handler.js"; + +describe("ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS", () => { + const previous = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + const restore = (): void => { + if (previous === undefined) delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + else process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = previous; + }; + + it("parses decimal milliseconds with a cap, 'off' restores the connect-only ladder, junk falls back", () => { + try { + delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + expect(transientRetryPolicy()).toEqual({ unitMs: 500, extended: true }); + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "250"; + expect(transientRetryPolicy()).toEqual({ unitMs: 250, extended: true }); + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "99999"; + expect(transientRetryPolicy().unitMs).toBe(10_000); + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = " OFF "; + expect(transientRetryPolicy().extended).toBe(false); + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "1e3"; + expect(transientRetryPolicy()).toEqual({ unitMs: 500, extended: true }); + expect(transientRetryPolicy(0)).toEqual({ unitMs: 0, extended: true }); // explicit seam wins + } finally { + restore(); + } + }); + + it("'off' retries never-connected failures only", async () => { + try { + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "off"; + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return new Response("x", { status: 503 }); }); + expect(resp.status).toBe(503); + expect(calls).toBe(1); + calls = 0; + await expect(dispatchWithConnectRetry(async () => { calls += 1; throw Object.assign(new Error("reset"), { code: "ECONNRESET" }); })).rejects.toThrow(/reset/); + expect(calls).toBe(1); + calls = 0; + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "0"; + const ok = await dispatchWithConnectRetry(async () => { calls += 1; if (calls === 1) throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); return new Response("ok"); }); + expect(ok.status).toBe(200); + expect(calls).toBe(2); + } finally { + restore(); + } + }); + + it("unit 0 skips even a short Retry-After (bounded tests, no sleeping operators)", async () => { + let calls = 0; + const started = Date.now(); + const resp = await dispatchWithConnectRetry(async () => { + calls += 1; + return calls === 1 ? new Response("later", { status: 429, headers: { "retry-after": "5" } }) : new Response("ok"); + }, { retryDelayMs: 0 }); + expect(resp.status).toBe(200); + expect(calls).toBe(2); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }); +} +function html(status: number, headers: Record = {}): Response { + return new Response("edge error", { status, headers: { "content-type": "text/html", ...headers } }); +} + +describe("transient pre-output retry ladder", () => { + it("retries gateway/edge statuses whose body is not a gateway business envelope", async () => { + for (const status of [500, 502, 503, 504, 524, 529]) { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { + calls += 1; + return calls < 3 ? html(status) : new Response("ok"); + }, { retryDelayMs: 0 }); + expect(resp.status).toBe(200); + expect(calls).toBe(3); + } + }); + + it("returns the last response once the attempt budget is spent", async () => { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return html(503); }, { retryDelayMs: 0 }); + expect(calls).toBe(MAX_TRANSIENT_ATTEMPTS); + expect(resp.status).toBe(503); + expect(await resp.text()).toContain("edge error"); // the surfaced response is still readable + }); + + it("never retries a recognised gateway business envelope, whatever the HTTP status", async () => { + for (const [status, body] of [ + [500, { code: 1005, msg: "exceed quota limit" }], + [503, { code: 3007, msg: "captcha verify failed" }], + [502, { code: 3001, msg: "rejected" }], + [429, { code: 429, msg: "rate limited" }], + ] as const) { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return json(status, body); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(resp.status).toBe(status); + expect((await resp.json()).code).toBe(body.code); // the body was inspected without being consumed + } + }); + + it("does not retry request-level errors", async () => { + for (const status of [400, 401, 403, 404, 409, 422]) { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return json(status, { error: { type: "x", message: "y" } }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(resp.status).toBe(status); + } + }); + + it("honours a numeric Retry-After on 429/503 up to the cap and surfaces it above the cap", async () => { + let calls = 0; + const quick = await dispatchWithConnectRetry(async () => { + calls += 1; + return calls === 1 ? html(429, { "retry-after": "0" }) : new Response("ok"); + }, { retryDelayMs: 0 }); + expect(quick.status).toBe(200); + expect(calls).toBe(2); + + calls = 0; + const tooLong = String(Math.ceil(TRANSIENT_RETRY_AFTER_CAP_MS / 1000) + 1); + const surfaced = await dispatchWithConnectRetry(async () => { calls += 1; return html(429, { "retry-after": tooLong }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(surfaced.status).toBe(429); + expect(surfaced.headers.get("retry-after")).toBe(tooLong); // the client decides + }); + + it("never inspects or retries a stream response", async () => { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { + calls += 1; + return new Response("event: error\ndata: {}\n\n", { status: 503, headers: { "content-type": "text/event-stream" } }); + }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(resp.status).toBe(503); + }); + + it("stops when the client aborts during the wait and never dispatches again", async () => { + const controller = new AbortController(); + let calls = 0; + const promise = dispatchWithConnectRetry(async () => { + calls += 1; + setTimeout(() => controller.abort(), 5); + return html(503); + }, { retryDelayMs: 200, signal: controller.signal, isAborted: () => controller.signal.aborted }); + await expect(promise).rejects.toThrow(/aborted/); + expect(calls).toBe(1); + }); + + it("reports each retry with its reason and delay", async () => { + const seen: Array<[number, string, number]> = []; + let calls = 0; + await dispatchWithConnectRetry(async () => { + calls += 1; + if (calls === 1) throw Object.assign(new Error("reset"), { code: "ECONNRESET" }); + if (calls === 2) return html(502); + return new Response("ok"); + }, { retryDelayMs: 0, onRetry: (attempt, reason, delayMs) => { seen.push([attempt, reason, delayMs]); } }); + expect(seen).toEqual([[1, "ECONNRESET", 0], [2, "HTTP 502", 0]]); + }); + + it("classifies thrown errors: connect vs drop vs never", () => { + expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNREFUSED" }))?.kind).toBe("connect"); + expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }))?.kind).toBe("drop"); + expect(transientErrorKind(new Error("socket hang up"))?.kind).toBe("drop"); + expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNRESET", postWrite: true }))).toBeNull(); + expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("y"), { postWrite: true }) }))).toBeNull(); + expect(transientErrorKind(Object.assign(new Error("cert"), { code: "CERT_HAS_EXPIRED" }))).toBeNull(); + expect(transientErrorKind(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBeNull(); + expect(transientErrorKind(new Error("something else"))).toBeNull(); + expect(transientErrorKind(null)).toBeNull(); + }); +}); diff --git a/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts b/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts index 9f74e0c..3d63a0a 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors-pool.test.ts @@ -151,6 +151,33 @@ describe("account-pool upstream recovery", () => { expect(auth.listAccounts().find((a) => a.id === "two")?.state).not.toBe("exhausted"); }); + it("never fails over when the pooled resend died after the write with the mark on a nested cause", async () => { + const rotator = createAccountRotator([{ id: "one", credential: first }, { id: "two", credential: second }]); + const auth = new AuthManager({ accountRotator: rotator }); + const handle = rotator.getCredentialHandle(); + const sent: string[] = []; + const pending = recoverAndMapUpstream({ + response: Response.json({ code: 1005, msg: "redacted upstream text" }, { status: 200 }), + auth, + credential: handle.credential, + handle, + plan: "coding-plan", + signal: new AbortController().signal, + quotaRetryDelaysMs: [0], + resend: async () => { throw new Error("bare resend must not be used"); }, + resendHandle: async (next) => { + sent.push(next.id); + // A wrapped transport error: the postWrite mark sits on the cause. + const inner = Object.assign(new Error("upstream closed before sending response headers"), { postWrite: true }); + throw new Error("dispatch failed", { cause: inner }); + }, + }); + await expect(pending).rejects.toThrow(/dispatch failed/); + expect(sent).toEqual(["one"]); + expect(auth.listAccounts().find((a) => a.id === "two")?.state).not.toBe("exhausted"); + expect(rotator.getCredentialHandle().id).toBe("one"); // the sticky account did not move + }); + it("aborts during the retry wait without sending anything or memoizing", async () => { // Legacy auth (no rotator): canResendCredential reflects only the retry // memo, so this pins that an aborted schedule proves nothing. In pool diff --git a/zcode-proxy-src/src/proxy/upstream-errors.ts b/zcode-proxy-src/src/proxy/upstream-errors.ts index 67c11b6..66b4c78 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors.ts @@ -1,4 +1,5 @@ import { decodeContentStream } from "./inflate.js"; +import { isPostWriteError } from "./ordered-transport.js"; import type { AuthManager } from "../auth/manager.js"; import type { Credential } from "../auth/types.js"; import type { AccountHandle } from "../auth/account-rotator.js"; @@ -98,6 +99,11 @@ async function readBounded(stream: ReadableStream): Promise {}); reader.releaseLock(); } } +/** Bounded, non-consuming look at a JSON body for a recognised gateway error envelope (never SSE). */ +export async function inspectGatewayEnvelope(resp: Response): Promise<{ status: number; type: string; message: string; code?: number; resetAt?: number } | null> { + return inspect(resp); +} + async function inspect(resp: Response): Promise<{ status: number; type: string; message: string; code?: number; resetAt?: number } | null> { if (resp.headers.get("content-type")?.includes("text/event-stream")) return null; try { @@ -197,11 +203,15 @@ export async function recoverAndMapUpstream(opts: { // A SENT retry came back exhausted: this is real evidence for the memo. confirmedExhausted = true; } catch (err) { - // A failure after the full request was written may have been - // processed upstream: never fail over to another account on top of - // it (the connect ladder refuses the same replay). The handler maps - // the rethrown error to a 502 without a further attempt. - if ((err as { postWrite?: unknown } | null)?.postWrite) throw err; + // A failure after the full request was written (flag on the error or + // a nested cause) may have been processed upstream: never fail over + // to another account on top of it (the transient ladder refuses the + // same replay). The handler maps the rethrown error to a 502 without + // a further attempt; the original envelope body is released first. + if (isPostWriteError(err)) { + void response.body?.cancel().catch(() => {}); + throw err; + } // The retry could not be sent (connect or captcha failure): keep the // current quota envelope so rotation below is not lost. scheduleCompleted = false; diff --git a/zcode-proxy-src/src/proxy/upstream.test.ts b/zcode-proxy-src/src/proxy/upstream.test.ts index 44d24fb..529f6de 100644 --- a/zcode-proxy-src/src/proxy/upstream.test.ts +++ b/zcode-proxy-src/src/proxy/upstream.test.ts @@ -562,6 +562,26 @@ describe("proxyRequest", () => { expect(text).toContain("message_stop"); }); + it("appends one terminal error event when the native Anthropic stream ends before message_stop", async () => { + const truncated = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-4.6","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}', + '', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}', + '', + ].join("\n"); + const fetchMock = mock(async (): Promise => new Response(truncated, { status: 200, headers: { "content-type": "text/event-stream" } })); + const auth = oauthAuth(); + const clientReq = makeClientReq('{"model":"glm-4.6","messages":[],"stream":true}'); + + const resp = await proxyRequest(clientReq, "anthropic", { config: testConfig, auth, fetchImpl: fetchMock as any }); + expect(resp.status).toBe(200); + const text = await resp.text(); + expect(fetchMock).toHaveBeenCalledTimes(1); // no replay after bytes were forwarded + expect(text.startsWith(truncated)).toBe(true); + expect(text.endsWith('event: error\ndata: {"type":"error","error":{"type":"upstream_incomplete","message":"Upstream stream ended before message_stop"}}\n\n')).toBe(true); + expect(text).not.toContain("event: message_stop"); // no fabricated stop event + }); + it("passes the Anthropic batch response through with raw-decompress transport", async () => { const fetchMock = mock(async (_req: Request, init?: RequestInit & { decompress?: boolean }): Promise => { expect(init?.decompress).toBe(false); From 2bdb2719df0c99e7d827fb9967dfb9e8334bac18 Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 16:15:22 +0000 Subject: [PATCH 4/6] proxy: retry the gateway codes the official client retries, frame-safe stream end; setup: --no-mcp never records MCP consent Review findings on the second package (independent review and counter-check), each verified against the code before the change. Proxy - transient ladder: a gateway envelope now decides by its code. The codes the official client retries (500, 1120, 1230, 1234, 1302, 1303, 1305, 1312, 2007, 3002) are retried with any HTTP status, terminal verdicts (quota, balance, captcha, model, authorization, authentication, 1210) never, any other code follows the HTTP status. A captcha challenge header is handed to the captcha layer even on a transient status (no repeated sends of a spent token). A TLS code anywhere in the error chain blocks a retry. Content-type checks are case-insensitive. Retry-After accepts an HTTP-date. The account is re-checked before every retry, and a refused retry hands back the last response intact. - error mapping keeps a bounded numeric Retry-After on 503 and 529 as well as 429, so a delay the ladder surfaced reaches the client. - native Anthropic streams are forwarded frame by frame: an unfinished last frame (the usual shape of a cut connection) is dropped so the appended `event: error` frame stays parseable. The frame uses the standard `api_error` type and names the cause in its message. A gzip/deflate/br-encoded native stream is decoded before the monitor and forwarded identity-encoded, so the guarantee also holds for clients that accept compression. Kit - `setup --harness X --no-mcp` no longer stores MCP consent; a later plain setup never registers the declined bridge. - `integrate` keeps an MCP consent recorded earlier instead of clearing it. - connection details bracket every IPv6 literal, not only ::1. Docs (en + de/es/ja/zh-CN): retry policy wording (codes, budget vs the official client, Retry-After statuses, account re-check, ordered transport), stream termination (api_error, dropped partial frame), quota failover only when the rotator has another account. Checked and refuted: CRLF frames were already recognised (`$` with the m flag matches before CR in Node and Bun); tests now pin it. --- cli/connection-details.mjs | 5 +- cli/harness-consent.mjs | 18 +-- cli/zcode-kit.mjs | 7 +- harnesses/README.de.md | 6 +- harnesses/README.es.md | 6 +- harnesses/README.ja.md | 6 +- harnesses/README.md | 6 +- harnesses/README.zh-CN.md | 6 +- tests/connection-details.test.mjs | 3 + tests/harness-consent.test.mjs | 24 +++- zcode-proxy-src/README.de.md | 2 +- zcode-proxy-src/README.es.md | 2 +- zcode-proxy-src/README.ja.md | 2 +- zcode-proxy-src/README.md | 2 +- zcode-proxy-src/README.zh-CN.md | 2 +- zcode-proxy-src/src/proxy/captcha-retry.ts | 3 + zcode-proxy-src/src/proxy/captcha.ts | 3 +- .../src/proxy/credential-recovery.test.ts | 4 +- zcode-proxy-src/src/proxy/handler.ts | 133 +++++++++++++----- .../src/proxy/responses-handler.ts | 3 +- .../src/proxy/sse-terminal.test.ts | 94 +++++++++++-- zcode-proxy-src/src/proxy/sse-terminal.ts | 127 ++++++++++++----- .../src/proxy/transient-retry.test.ts | 84 ++++++++++- zcode-proxy-src/src/proxy/upstream-errors.ts | 10 +- zcode-proxy-src/src/proxy/upstream.test.ts | 34 +++-- 25 files changed, 456 insertions(+), 136 deletions(-) diff --git a/cli/connection-details.mjs b/cli/connection-details.mjs index 7eb4877..3c5ca89 100644 --- a/cli/connection-details.mjs +++ b/cli/connection-details.mjs @@ -8,6 +8,7 @@ // `zcode-kit models --show-key` remains the explicit opt-in for exporting the // key to another program. import { existsSync, readFileSync } from "node:fs"; +import { isIP } from "node:net"; export const DEFAULT_MODEL_IDS = ["glm-5.3", "glm-5.3-flash"]; export const REDACTED_KEY = "(redacted: not an interactive terminal — print it with: zcode-kit models --show-key)"; @@ -55,8 +56,8 @@ export function isLoopbackHost(host) { */ export function connectionDetailsLines({ port, key, models = DEFAULT_MODEL_IDS, modelsSource = "config", source = "configured", reveal = false, host = "127.0.0.1", responsesEnabled = true, indent = " " }) { const loopback = isLoopbackHost(host); - // An IPv6-only listener is reachable as [::1], not as 127.0.0.1. - const urlHost = host === "::1" ? "[::1]" : loopback ? "127.0.0.1" : host; + // An IPv6 literal needs brackets in a URL; the loopback one is [::1], not 127.0.0.1. + const urlHost = isIP(host) === 6 ? `[${host}]` : loopback ? "127.0.0.1" : host; const base = `http://${urlHost}:${port}`; const keyText = reveal && typeof key === "string" && key.length ? key : REDACTED_KEY; const verified = source === "running"; diff --git a/cli/harness-consent.mjs b/cli/harness-consent.mjs index 54bf068..c77a52d 100644 --- a/cli/harness-consent.mjs +++ b/cli/harness-consent.mjs @@ -239,19 +239,21 @@ export function ownedIntegration(adapter, ctx) { * - otherwise the harness is asked; y configures, n skips, no answer skips * without changing the stored decision. * `consent` marks decisions that authorize the provider integration; `mcp` - * marks the narrower consent for the kit's MCP bridge: an explicit selection, - * or a y given after the MCP note was shown (`mcpOffered`), or a stored - * decision that recorded it. A refresh, an `integrate` decision or a y - * without the note never registers MCP. + * marks the narrower consent for the kit's MCP bridge: an explicit selection + * unless the bridge was declined (`mcpAllowed` false with --no-mcp), or a y + * given after the MCP note was shown (`mcpOffered`), or a stored decision + * that recorded it. A refresh, an `integrate` decision or a y without the + * note never registers MCP. */ -export async function decideHarness({ id, label, detected, stored, storedMcp = false, unreadable = false, owned = false, explicit, reask = false, ask, mcpOffered = false }) { +export async function decideHarness({ id, label, detected, stored, storedMcp = false, unreadable = false, owned = false, explicit, reask = false, ask, mcpOffered = false, mcpAllowed = true }) { const via = (source) => (source === "flag" ? "--harness" : HARNESS_SELECTION_ENV); // Re-asking needs a terminal: without one, stored decisions keep standing. const reasking = reask && typeof ask === "function"; if (explicit) { - // An explicit selection is documented to cover the MCP bridge (--no-mcp - // opts out), so it records MCP consent as well. - if (explicit.ids.includes(id)) return { action: "configure", source: explicit.source, reason: `selected via ${via(explicit.source)}`, record: true, consent: true, mcp: true }; + // An explicit selection is documented to cover the MCP bridge, so it + // records MCP consent as well — unless --no-mcp declined the bridge, in + // which case nothing must remember it as consented. + if (explicit.ids.includes(id)) return { action: "configure", source: explicit.source, reason: `selected via ${via(explicit.source)}`, record: true, consent: true, mcp: mcpAllowed === true }; if (explicit.none && detected) return { action: "skip", source: explicit.source, reason: `${via(explicit.source)}=none`, record: true, consent: false }; return { action: "ignore", source: explicit.source, reason: "not selected", record: false, consent: false }; } diff --git a/cli/zcode-kit.mjs b/cli/zcode-kit.mjs index 4f3aec6..a813520 100644 --- a/cli/zcode-kit.mjs +++ b/cli/zcode-kit.mjs @@ -286,6 +286,7 @@ async function cmdSetup() { reask: flags.reask === true, ask, mcpOffered, + mcpAllowed: !flags["no-mcp"], }); } catch (err) { if (err?.code !== ABORTED) throw err; @@ -523,8 +524,10 @@ async function cmdIntegrate() { adapter.apply(ctx, tx, (m) => console.log(m)); // An explicit integrate command is consent for this harness: later // setup/update/repair runs keep it current without asking again. It is - // not MCP consent (integrate never registers the bridge). - if (!recordHarnessChoice(ctx, tx, readHarnessChoices(ctx), id, "configured", "integrate")) { + // not MCP consent (integrate never registers the bridge), and it neither + // grants nor revokes an MCP consent recorded earlier. + const choices = readHarnessChoices(ctx); + if (!recordHarnessChoice(ctx, tx, choices, id, "configured", "integrate", undefined, { mcp: choices.harnesses[id]?.mcp === true })) { console.log(`WARN: the decision for ${id} was not recorded — its decision file is unreadable; fix or remove it (see zcode-kit setup).`); } } finally { diff --git a/harnesses/README.de.md b/harnesses/README.de.md index ad1499b..0c11d39 100644 --- a/harnesses/README.de.md +++ b/harnesses/README.de.md @@ -103,9 +103,9 @@ Proxy-Prüfung belegt keinen Erfolg beim nativen Provider. Details: ## Kontingent & Fehlerbilder - `GET /quota` (authentifiziert) zeigt die Token-Buckets je Modell. -- Kontingent erschöpft → HTTP 400 `[1005] exceed quota limit`. Der Proxy wiederholt denselben Account nach einem wachsenden Zeitplan (bis ~65s), bevor er weiterwechselt; tritt der Fehler weiter auf, warte, bis der Anbieter wieder Kontingent bereitstellt. +- Kontingent erschöpft → HTTP 400 `[1005] exceed quota limit`. Der Proxy wiederholt denselben Account nach einem wachsenden Zeitplan (standardmäßig bis ~65s), bevor er auf einen anderen Account wechselt, sofern der Rotator einen hat; tritt der Fehler weiter auf, warte, bis der Anbieter wieder Kontingent bereitstellt. - `[3007] captcha verify failed` → Gateway-Anti-Absicherung. Der Proxy wiederholt einmal mit einem frisch erzeugten Captcha-Token; schlägt das erneut fehl, lege eine Pause ein. -- Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 mit kurzem `Retry-After`) werden bis zu 3-mal auf demselben Konto mit wachsender Wartezeit wiederholt, wie es der offizielle Client tut. Ein erkannter Gateway-Fehlercode, ein Authentifizierungs- oder Modellfehler und alles nach begonnener Ausgabe werden nie wiederholt; ein `Retry-After` über 15 Sekunden wird an den Client durchgereicht. Einstellbar über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen; proxy start/restart reicht den Wert durch). -- Ein vom Upstream abgebrochener Stream endet mit einer Fehlermeldung statt einer stillen Kürzung: Chat-Streams mit `data: {"error":…}`, Responses-Streams mit `response.failed`, native Anthropic-Streams mit einem `event: error`-Frame (`upstream_incomplete` oder `upstream_stream_error`). Nach begonnener Ausgabe wird nichts wiederholt; sende den Turn aus dem Harness erneut. +- Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 sowie die Gateway-Fehlercodes, die der offizielle Client wiederholt) werden bis zu 3-mal auf demselben Konto mit wachsender Wartezeit wiederholt; das Budget ist kleiner als beim offiziellen Client (Basiswartezeit 1 s, verdoppelt; `Retry-After` bis 15 Sekunden beachtet), und das Konto wird vor jeder Wiederholung erneut geprüft. Gateway-Entscheidungen (Kontingent, Guthaben, Captcha, Modell, Authentifizierung), Anfragefehler und alles nach begonnener Ausgabe werden nie wiederholt; ein `Retry-After` über 15 Sekunden wird bei 429, 503 und 529 an den Client durchgereicht. Einstellbar über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen; proxy start/restart reicht den Wert durch). +- Ein vom Upstream abgebrochener Stream endet mit einer Fehlermeldung statt einer stillen Kürzung: Chat-Streams mit `data: {"error":…}`, Responses-Streams mit `response.failed`, native Anthropic-Streams mit einem `event: error`-Frame vom Typ `api_error`, dessen Meldung die Ursache nennt (`upstream_incomplete` oder `upstream_stream_error`); ein unvollständiger letzter Frame wird verworfen, damit der Fehler-Frame lesbar bleibt. Nach begonnener Ausgabe wird nichts wiederholt; sende den Turn aus dem Harness erneut. - `401 start_plan_jwt_invalid` → Desktop-Anmeldung prüfen und mit `zcode-kit auth login zai` erneuern. Mit `zcode-kit auth login zai --import` den aktuell aktiven `zai`/`start-plan`-Login aus Desktop 0.16.9 mit ausdrücklich konfiguriertem Plan importieren. Eine vorhandene `credentials.json` ist maßgeblich; bei ungültigen Anmeldedaten erfolgt kein stiller Rückgriff auf `config.json`. Moderne `coding-plan`-Logins verwenden stattdessen normales OAuth; der Import erstellt oder ermittelt keine API-Schlüssel. - `[1210]` bei Flash → prüfen, ob Thinking aktiviert ist, und `low`, `high` oder `max` wählen, statt Thinking zu deaktivieren. Der Proxy normalisiert deaktiviertes Thinking auf `low`; siehe den Flash-Hinweis oben. diff --git a/harnesses/README.es.md b/harnesses/README.es.md index 80150d0..e372871 100644 --- a/harnesses/README.es.md +++ b/harnesses/README.es.md @@ -104,9 +104,9 @@ nativo. Detalles: [puente MCP](../mcp/zcode-harness-mcp/README.es.md). ## Cuota y modos de error - `GET /quota` (autenticado) muestra los buckets de tokens por modelo. -- Cuota agotada → HTTP 400 `[1005] exceed quota limit`. El proxy reintenta la misma cuenta con un calendario creciente (hasta ~65s) antes de cambiar de cuenta; si aún lo ves, espera a que el proveedor restablezca la cuota. +- Cuota agotada → HTTP 400 `[1005] exceed quota limit`. El proxy reintenta la misma cuenta con un calendario creciente (hasta ~65s por defecto) antes de cambiar a otra cuenta, cuando el rotador tiene una; si aún lo ves, espera a que el proveedor restablezca la cuota. - `[3007] captcha verify failed` → anti-abuso del gateway. El proxy reintenta una vez con un token CAPTCHA recién emitido; si aún falla, haz una pausa. -- Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 con un `Retry-After` corto) se reintentan hasta 3 veces en la misma cuenta con una espera creciente, como hace el cliente oficial. Un código de error del gateway reconocido, un error de autenticación o de modelo y cualquier cosa tras el inicio de la salida nunca se reintentan; un `Retry-After` superior a 15 segundos se pasa al cliente. Ajústalo con `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones que nunca se establecieron; proxy start/restart lo transmite). -- Un flujo que el upstream corta termina con un mensaje de error en lugar de un truncamiento silencioso: los flujos de chat con `data: {"error":…}`, los de Responses con `response.failed`, los flujos Anthropic nativos con un frame `event: error` (`upstream_incomplete` o `upstream_stream_error`). Nada se repite una vez iniciada la salida; vuelve a enviar el turno desde el harness. +- Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 y los códigos de error del gateway que el cliente oficial reintenta) se reintentan hasta 3 veces en la misma cuenta con una espera creciente; el presupuesto es menor que el del cliente oficial (espera base de 1 s que se duplica, `Retry-After` respetado hasta 15 segundos) y la cuenta se vuelve a comprobar antes de cada reintento. Los veredictos del gateway (cuota, saldo, captcha, modelo, autenticación), los errores de la solicitud y cualquier cosa tras el inicio de la salida nunca se reintentan; un `Retry-After` superior a 15 segundos se pasa al cliente en 429, 503 y 529. Ajústalo con `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones que nunca se establecieron; proxy start/restart lo transmite). +- Un flujo que el upstream corta termina con un mensaje de error en lugar de un truncamiento silencioso: los flujos de chat con `data: {"error":…}`, los de Responses con `response.failed`, los flujos Anthropic nativos con un frame `event: error` de tipo `api_error` cuyo mensaje indica la causa (`upstream_incomplete` o `upstream_stream_error`); un último frame incompleto se descarta para que el frame de error siga siendo legible. Nada se repite una vez iniciada la salida; vuelve a enviar el turno desde el harness. - `401 start_plan_jwt_invalid` → comprueba la sesión de Desktop y renuévala con `zcode-kit auth login zai`. Usa `zcode-kit auth login zai --import` para el login activo de `zai`/`start-plan` en Desktop 0.16.9 con un plan configurado explícitamente. Si existe `credentials.json`, es la fuente autoritativa; unas credenciales inválidas no provocan una vuelta silenciosa a `config.json`. Los logins modernos de `coding-plan` usan el OAuth normal; la importación no crea ni obtiene claves API. - `[1210]` con Flash → comprueba que thinking esté activado y elige `low`, `high` o `max`, en lugar de desactivarlo. El proxy normaliza thinking desactivado a `low`; consulta la nota sobre Flash anterior. diff --git a/harnesses/README.ja.md b/harnesses/README.ja.md index b9f252f..a19940b 100644 --- a/harnesses/README.ja.md +++ b/harnesses/README.ja.md @@ -104,9 +104,9 @@ Desktop が必要な場合がありますが、プロバイダーがモデル呼 ## クォータとエラーの型 - `GET /quota`(認証付き)はモデルごとのトークンバケットを表示します。 -- クォータ消費済み → HTTP 400 `[1005] exceed quota limit`。プロキシは同じアカウントを成長間隔で再試行し(最大約65秒)、それから次のアカウントへ切り替えます。それでも表示される場合は、プロバイダーによる利用枠の回復を待ってください。 +- クォータ消費済み → HTTP 400 `[1005] exceed quota limit`。プロキシは同じアカウントを成長間隔で再試行し(既定で最大約65秒)、ローテーターに別のアカウントがあればその後に切り替えます。それでも表示される場合は、プロバイダーによる利用枠の回復を待ってください。 - `[3007] captcha verify failed` → ゲートウェイ側のアンチアビューズ。プロキシは新しく発行した CAPTCHA トークンで一度再試行します。それでも失敗する場合は、しばらく休憩してください。 -- 出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、短い `Retry-After` 付きの 429)は、公式クライアントと同様に同じアカウントで待ち時間を増やしながら最大 3 回再試行します。認識されたゲートウェイのエラーコード、認証やモデルのエラー、出力開始後の障害は再試行しません。15 秒を超える `Retry-After` はクライアントにそのまま渡します。`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で調整できます(基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します。proxy start/restart が値を引き継ぎます)。 -- アップストリームが途中で切断したストリームは、無言で途切れる代わりにエラーで終わります。チャットのストリームは `data: {"error":…}`、Responses は `response.failed`、ネイティブの Anthropic ストリームは 1 つの `event: error` フレーム(`upstream_incomplete` または `upstream_stream_error`)です。出力開始後は何も再送しません。ハーネスからターンを送り直してください。 +- 出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、429、公式クライアントが再試行するゲートウェイのエラーコード)は、同じアカウントで待ち時間を増やしながら最大 3 回再試行します。予算は公式クライアントより小さく(基本待ち時間 1 秒で倍増、`Retry-After` は 15 秒まで尊重)、再試行のたびにアカウントを再確認します。ゲートウェイの判定(クォータ、残高、CAPTCHA、モデル、認証)、リクエストのエラー、出力開始後の障害は再試行しません。15 秒を超える `Retry-After` は 429/503/529 でクライアントにそのまま渡します。`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で調整できます(基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します。proxy start/restart が値を引き継ぎます)。 +- アップストリームが途中で切断したストリームは、無言で途切れる代わりにエラーで終わります。チャットのストリームは `data: {"error":…}`、Responses は `response.failed`、ネイティブの Anthropic ストリームは `api_error` 型の `event: error` フレーム 1 つで、メッセージに原因(`upstream_incomplete` または `upstream_stream_error`)が入ります。未完成の最後のフレームは、エラーフレームを正しく解析できるよう破棄します。出力開始後は何も再送しません。ハーネスからターンを送り直してください。 - `401 start_plan_jwt_invalid` → Desktop のログインを確認し、`zcode-kit auth login zai` で更新。プランが明示的に設定された Desktop 0.16.9 の現在アクティブな `zai`/`start-plan` ログインには `zcode-kit auth login zai --import` を使います。`credentials.json` が存在する場合はそれが正となり、認証情報が無効でも `config.json` へ暗黙にはフォールバックしません。新形式の `coding-plan` ログインでは通常の OAuth を使います。インポートは API キーの作成や取得を行いません。 - Flash で `[1210]` → thinking が有効か確認し、無効にする代わりに `low`、`high`、`max` を選びます。プロキシは、無効にした thinking を `low` に正規化します。上記の Flash の注記を参照してください。 diff --git a/harnesses/README.md b/harnesses/README.md index 8b71288..025e793 100644 --- a/harnesses/README.md +++ b/harnesses/README.md @@ -134,9 +134,9 @@ establish native-provider acceptance. Details: ## Quota & error modes - `GET /quota` (authenticated) shows the token buckets per model. -- Quota exhausted → HTTP 400 `[1005] exceed quota limit`. The proxy retries the same account on a growing schedule (up to ~65s) before failing over; if you still see it, wait for the provider to restore quota. +- Quota exhausted → HTTP 400 `[1005] exceed quota limit`. The proxy retries the same account on a growing schedule (up to ~65s by default) before failing over to another account, when the rotator has one; if you still see it, wait for the provider to restore quota. - `[3007] captcha verify failed` → gateway anti-abuse. The proxy retries once with a freshly minted captcha token; if it still fails, take a pause. -- Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429 with a short `Retry-After`) are retried up to 3 times on the same account with a growing delay, mirroring the official client. A recognised gateway error code, an authentication or model error, and anything after output has started are never retried; a `Retry-After` above 15 seconds is passed to the client instead. Tune with `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (base delay in milliseconds, default 500, capped at 10000; `off` keeps only the retry of connections that were never established; proxy start/restart passes it through). -- A stream that the upstream cuts off ends with an error payload instead of a silent truncation: chat streams get `data: {"error":…}`, Responses streams `response.failed`, native Anthropic streams one `event: error` frame (`upstream_incomplete` or `upstream_stream_error`). Nothing is replayed after output has started; send the turn again from the harness. +- Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429, and the gateway error codes the official client retries) are retried up to 3 times on the same account with a growing delay; the budget is smaller than the official client's (base delay 1 s doubling, `Retry-After` honoured up to 15 seconds), and the account is re-checked before every retry. Gateway verdicts (quota, balance, captcha, model, authentication), request errors and anything after output has started are never retried; a `Retry-After` above 15 seconds is passed to the client on 429, 503 and 529. Tune with `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (base delay in milliseconds, default 500, capped at 10000; `off` keeps only the retry of connections that were never established; proxy start/restart passes it through). +- A stream that the upstream cuts off ends with an error payload instead of a silent truncation: chat streams get `data: {"error":…}`, Responses streams `response.failed`, native Anthropic streams one `event: error` frame of type `api_error` whose message names the cause (`upstream_incomplete` or `upstream_stream_error`); an unfinished last frame is dropped so that the error frame stays parseable. Nothing is replayed after output has started; send the turn again from the harness. - `401 start_plan_jwt_invalid` → check your Desktop login and renew it with `zcode-kit auth login zai`. Use `zcode-kit auth login zai --import` for the current active Desktop 0.16.9 `zai`/`start-plan` login with an explicitly configured plan. Existing `credentials.json` is authoritative; invalid credentials do not silently fall back to `config.json`. Modern `coding-plan` logins use normal OAuth instead; import does not create or resolve API keys. - `[1210]` with Flash → check that thinking is enabled and choose `low`, `high`, or `max`, rather than disabling it. The proxy normalizes disabled thinking to `low`; see the Flash note above. diff --git a/harnesses/README.zh-CN.md b/harnesses/README.zh-CN.md index 7819c1f..e4ab59a 100644 --- a/harnesses/README.zh-CN.md +++ b/harnesses/README.zh-CN.md @@ -100,9 +100,9 @@ GLM-5.3-Flash 已通过代理路径验证。原生目录中存在模型条目, ## 配额与错误形态 - `GET /quota`(需认证)显示各模型的令牌桶。 -- 配额耗尽 → HTTP 400 `[1005] exceed quota limit`。代理会按递增间隔重试同一账号(最长约65秒),然后切换账号;如果仍然出现,请等待服务商恢复额度。 +- 配额耗尽 → HTTP 400 `[1005] exceed quota limit`。代理会按递增间隔重试同一账号(默认最长约65秒),然后在轮换器有其他账号时切换账号;如果仍然出现,请等待服务商恢复额度。 - `[3007] captcha verify failed` → 网关反滥用机制。代理会使用新获取的 CAPTCHA 令牌自动重试一次;如果仍然失败,请暂停片刻。 -- 输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、带较短 `Retry-After` 的 429)会在同一账号上以递增等待最多重试 3 次,与官方客户端一致。已识别的网关错误码、认证或模型错误以及输出开始后的任何故障都不会重试;超过 15 秒的 `Retry-After` 会直接交给客户端处理。可用 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 调整(基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试;proxy start/restart 会传递该值)。 -- 被上游中断的流会以错误结束,而不是无声截断:聊天流为 `data: {"error":…}`,Responses 流为 `response.failed`,原生 Anthropic 流为一个 `event: error` 帧(`upstream_incomplete` 或 `upstream_stream_error`)。输出开始后不会重放任何内容;请从 harness 重新发送该轮。 +- 输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、429,以及官方客户端会重试的网关错误码)会在同一账号上以递增等待最多重试 3 次;预算比官方客户端小(基础等待 1 秒并翻倍,`Retry-After` 最多遵守 15 秒),每次重试前都会重新检查账号。网关裁定(配额、余额、验证码、模型、认证)、请求错误以及输出开始后的任何故障都不会重试;超过 15 秒的 `Retry-After` 会在 429、503 和 529 上直接交给客户端。可用 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 调整(基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试;proxy start/restart 会传递该值)。 +- 被上游中断的流会以错误结束,而不是无声截断:聊天流为 `data: {"error":…}`,Responses 流为 `response.failed`,原生 Anthropic 流为一个类型为 `api_error` 的 `event: error` 帧,其消息说明原因(`upstream_incomplete` 或 `upstream_stream_error`);未完成的最后一帧会被丢弃,以保证错误帧可解析。输出开始后不会重放任何内容;请从 harness 重新发送该轮。 - `401 start_plan_jwt_invalid` → 检查 Desktop 登录,并通过 `zcode-kit auth login zai` 更新。对于 Desktop 0.16.9 当前激活且已明确配置计划的 `zai`/`start-plan` 登录,使用 `zcode-kit auth login zai --import`。只要存在 `credentials.json`,就以它为准;凭据无效时不会静默回退到 `config.json`。新版 `coding-plan` 登录使用常规 OAuth;导入不会创建或获取 API 密钥。 - Flash 返回 `[1210]` → 检查是否已启用 thinking,并选择 `low`、`high` 或 `max`,而不是禁用它。代理会将禁用的 thinking 规范化为 `low`;参见上方 Flash 说明。 diff --git a/tests/connection-details.test.mjs b/tests/connection-details.test.mjs index 67565ae..bc389b2 100644 --- a/tests/connection-details.test.mjs +++ b/tests/connection-details.test.mjs @@ -70,6 +70,9 @@ test("formatter: a verified proxy says when the model list came from configurati const v6 = connectionDetailsLines({ port: 1, key: KEY, host: "::1" }).join("\n"); assert.match(v6, /http:\/\/\[::1\]:1\/v1/); assert.doesNotMatch(v6, /WARNING/); + const v6Global = connectionDetailsLines({ port: 1, key: KEY, host: "fd00::5" }).join("\n"); + assert.match(v6Global, /http:\/\/\[fd00::5\]:1\/v1/, "every IPv6 literal gets brackets in the URL"); + assert.match(v6Global, /WARNING: server.host is fd00::5/); }); test("configuredServer reads host and the Responses switch, with template defaults when absent", (t) => { diff --git a/tests/harness-consent.test.mjs b/tests/harness-consent.test.mjs index b384dc2..7321bc8 100644 --- a/tests/harness-consent.test.mjs +++ b/tests/harness-consent.test.mjs @@ -127,6 +127,7 @@ test("decideHarness: explicit lists win, stored decisions stand, answers decide, assert.equal((await decideHarness({ ...base, explicit: flag, ask: yes })).action, "ignore"); const selected = await decideHarness({ ...base, id: "pi", explicit: flag, detected: false, ask: yes }); assert.deepEqual(selected, { action: "configure", source: "flag", reason: "selected via --harness", record: true, consent: true, mcp: true }, "an explicit selection is documented to cover the MCP bridge"); + assert.equal((await decideHarness({ ...base, id: "pi", explicit: flag, detected: false, ask: yes, mcpAllowed: false })).mcp, false, "--no-mcp: an explicit selection never records MCP consent"); // MCP consent is separate from provider consent: a y covers the bridge only // when the MCP note was shown before the question; stored decisions carry it. assert.equal(y.mcp, false, "y without the MCP note is provider consent only"); @@ -494,7 +495,7 @@ test("an unreadable decision file blocks the refresh shortcut and is left alone; assert.equal(readFileSync(f.choice("omp"), "utf8"), "{ broken", "still never overwritten"); }); -test("one failing harness does not stop the others: its partial writes are undone, summary distinguishes configured and failed, exit 1", async (t) => { +test("one failing harness does not stop the others: its partial writes are undone, summary distinguishes configured and failed, exit 20", async (t) => { const f = fixture(t); writeFileSync(f.pi, "{ this is not json"); const r = await run(f, ["setup", "--harness", "omp,pi"]); @@ -551,6 +552,27 @@ test("integrate records provider consent only: a later setup refreshes the harne assert.equal(JSON.parse(readFileSync(f.choice("omp"), "utf8")).mcp, true); }); +test("--no-mcp with an explicit selection records provider consent only; later runs never register the declined bridge; integrate keeps a recorded MCP consent", async (t) => { + const f = fixture(t); + const r1 = await run(f, ["setup", "--harness", "omp", "--no-mcp"]); + assert.equal(r1.code, 0, r1.text); + assert.match(r1.stdout, /\[OK\]\s+OMP \/ Oh My Pi/); + assert.equal(existsSync(f.mcp), false, "--no-mcp skips the bridge"); + assert.equal(JSON.parse(readFileSync(f.choice("omp"), "utf8")).mcp, undefined, "a declined bridge is not stored as consent"); + const r2 = await run(f, ["setup"]); + assert.equal(r2.code, 0, r2.text); + assert.equal(existsSync(f.mcp), false, "a later plain setup never registers the bridge the user declined"); + const r3 = await run(f, ["setup", "--harness", "omp"]); + assert.equal(r3.code, 0, r3.text); + assert.equal(existsSync(f.mcp), true, "selecting the harness again without --no-mcp is the documented MCP consent"); + assert.equal(JSON.parse(readFileSync(f.choice("omp"), "utf8")).mcp, true); + const r4 = await run(f, ["integrate", "omp"]); + assert.equal(r4.code, 0, r4.text); + const after = JSON.parse(readFileSync(f.choice("omp"), "utf8")); + assert.equal(after.source, "integrate"); + assert.equal(after.mcp, true, "integrate neither grants nor revokes the MCP consent recorded earlier"); +}); + test("rolling back a setup removes the decisions it recorded together with the integration", async (t) => { const f = fixture(t); const r = await run(f, ["setup", "--harness", "omp"]); diff --git a/zcode-proxy-src/README.de.md b/zcode-proxy-src/README.de.md index 3901be8..0ce63cf 100644 --- a/zcode-proxy-src/README.de.md +++ b/zcode-proxy-src/README.de.md @@ -56,7 +56,7 @@ nur Metadaten. Die lokale Korrektur dekodiert gzip, deflate und Brotli vor der Auswertung übersetzter Streams oder JSON-Fehlerantworten. Leere oder nicht dekodierbare Batch-Antworten werden als `upstream_invalid_response` gemeldet, nicht als erfolgreiche leere Antworten. Der Proxy ersetzt das Arbeitsverzeichnis des aufrufenden Harness nicht durch sein eigenes; `ZCODE_IDENTITY_ENV_CWD` bleibt ein ausdrücklicher Override. -Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 mit kurzem `Retry-After`) werden wie beim offiziellen Client bis zu dreimal auf demselben Konto mit wachsender Wartezeit wiederholt; erkannte Gateway-Fehlercodes, Authentifizierungs- oder Modellfehler und alles nach begonnener Ausgabe werden nie wiederholt. Ein nativer Anthropic-Stream, der ohne `message_stop` endet, erhält einen abschließenden `event: error`-Frame (`upstream_incomplete`, bei fehlgeschlagenem Lesen `upstream_stream_error`), damit Clients den Fehler statt einer stillen Kürzung sehen. +Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 sowie die Gateway-Fehlercodes, die der offizielle Client wiederholt) werden bis zu dreimal auf demselben Konto mit wachsender Wartezeit wiederholt, mit kleinerem Budget als beim offiziellen Client (Basiswartezeit 1 s, verdoppelt; eingestellt über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen); das Konto wird vor jeder Wiederholung erneut geprüft, ein `Retry-After` wird bis 15 Sekunden beachtet und darüber an den Client durchgereicht, und Gateway-Entscheidungen (Kontingent, Guthaben, Captcha, Modell, Authentifizierung), Anfragefehler und alles nach begonnener Ausgabe werden nie wiederholt. Auf dem geordneten Transport für erzwungene Client-Sitzungen wird auch ein Fehler nach vollständig geschriebener Anfrage nicht wiederholt. Ein nativer Anthropic-Stream, der ohne `message_stop` endet, erhält einen abschließenden `event: error`-Frame vom Typ `api_error` (die Meldung nennt `upstream_incomplete`, bei fehlgeschlagenem Lesen `upstream_stream_error`); ein unvollständiger letzter Frame wird verworfen, damit Clients einen lesbaren Fehler statt einer stillen Kürzung sehen. ## Sicherheit diff --git a/zcode-proxy-src/README.es.md b/zcode-proxy-src/README.es.md index 5479d84..8114dd4 100644 --- a/zcode-proxy-src/README.es.md +++ b/zcode-proxy-src/README.es.md @@ -56,7 +56,7 @@ depuración contienen únicamente metadatos. La corrección local decodifica gzip, deflate y Brotli antes de interpretar los flujos traducidos o las respuestas de error JSON. Los cuerpos vacíos o imposibles de decodificar se notifican como `upstream_invalid_response`, no como respuestas vacías correctas. El proxy no sustituye el directorio del asistente por el de su proceso; `ZCODE_IDENTITY_ENV_CWD` sigue siendo una anulación explícita. -Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 con un `Retry-After` corto) se reintentan hasta tres veces en la misma cuenta con una espera creciente, como hace el cliente oficial; los códigos de error del gateway reconocidos, los errores de autenticación o de modelo y cualquier cosa tras el inicio de la salida nunca se reintentan. Un flujo Anthropic nativo que termina sin `message_stop` recibe un frame final `event: error` (`upstream_incomplete`, o `upstream_stream_error` si falló la lectura) para que los clientes vean el fallo en lugar de un truncamiento silencioso. +Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 y los códigos de error del gateway que el cliente oficial reintenta) se reintentan hasta tres veces en la misma cuenta con una espera creciente, con un presupuesto menor que el del cliente oficial (espera base de 1 s que se duplica, fijada por `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones nunca establecidas); la cuenta se vuelve a comprobar antes de cada reintento, un `Retry-After` se respeta hasta 15 segundos y por encima se pasa al cliente, y los veredictos del gateway (cuota, saldo, captcha, modelo, autenticación), los errores de la solicitud y cualquier cosa tras el inicio de la salida nunca se reintentan. En el transporte ordenado de las sesiones de cliente forzadas, un fallo posterior a la escritura completa de la solicitud tampoco se reintenta. Un flujo Anthropic nativo que termina sin `message_stop` recibe un frame final `event: error` de tipo `api_error` (su mensaje indica `upstream_incomplete`, o `upstream_stream_error` si falló la lectura); un último frame incompleto se descarta para que los clientes vean un fallo legible en lugar de un truncamiento silencioso. ## Seguridad diff --git a/zcode-proxy-src/README.ja.md b/zcode-proxy-src/README.ja.md index 5f9d59a..eabaf4c 100644 --- a/zcode-proxy-src/README.ja.md +++ b/zcode-proxy-src/README.ja.md @@ -52,7 +52,7 @@ CAPTCHA 成功を証明するものでもありません。従来の診断用バ ローカル修正では、変換対象のストリームや JSON エラー応答を解釈する前に gzip、deflate、Brotli を展開します。空または展開できないバッチ応答は、空の成功応答ではなく `upstream_invalid_response` として通知します。プロキシは自身の作業ディレクトリを呼び出し元のワークスペースとして提示しません。`ZCODE_IDENTITY_ENV_CWD` は明示的な上書きとして維持されます。 -出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、短い `Retry-After` 付きの 429)は、公式クライアントと同様に同じアカウントで待ち時間を増やしながら最大 3 回再試行します。認識されたゲートウェイのエラーコード、認証やモデルのエラー、出力開始後の障害は再試行しません。`message_stop` なしで終わったネイティブの Anthropic ストリームには、無言の途切れではなく障害が分かるよう、最後に 1 つの `event: error` フレーム(`upstream_incomplete`、読み取り失敗時は `upstream_stream_error`)を付加します。 +出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、429、公式クライアントが再試行するゲートウェイのエラーコード)は、同じアカウントで待ち時間を増やしながら最大 3 回再試行します。予算は公式クライアントより小さく(基本待ち時間 1 秒で倍増、`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で設定:基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します)、再試行のたびにアカウントを再確認し、`Retry-After` は 15 秒まで尊重してそれを超える場合はクライアントに渡します。ゲートウェイの判定(クォータ、残高、CAPTCHA、モデル、認証)、リクエストのエラー、出力開始後の障害は再試行しません。強制されたクライアントセッション用の順序付きトランスポートでは、リクエストを書き終えた後の障害も再試行しません。`message_stop` なしで終わったネイティブの Anthropic ストリームには、最後に `api_error` 型の `event: error` フレーム 1 つを付加します(メッセージは `upstream_incomplete`、読み取り失敗時は `upstream_stream_error` を示します)。未完成の最後のフレームは破棄するので、クライアントは無言の途切れではなく解析できる障害を受け取ります。 ## セキュリティ diff --git a/zcode-proxy-src/README.md b/zcode-proxy-src/README.md index b6a253e..2f60f4d 100644 --- a/zcode-proxy-src/README.md +++ b/zcode-proxy-src/README.md @@ -50,7 +50,7 @@ Do not use those switches. The solver, its security gates, and the callable The local repair decodes gzip, deflate, and Brotli before interpreting translated streams or JSON error envelopes. Empty or undecodable batch bodies are reported as `upstream_invalid_response`, not successful empty answers. The proxy does not substitute its daemon directory for the calling harness workspace; `ZCODE_IDENTITY_ENV_CWD` remains an explicit override. -Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429 with a short `Retry-After`) are retried up to three times on the same account with a growing delay, as the official client does; recognised gateway error codes, authentication or model errors, and anything after output has started are never retried (`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` sets the base delay in milliseconds, default 500; `off` keeps only the retry of never-established connections). A native Anthropic stream that ends without `message_stop` receives one terminal `event: error` frame (`upstream_incomplete`, or `upstream_stream_error` when the read failed) so clients see the failure instead of a silent truncation. +Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429, and the gateway error codes the official client retries) are retried up to three times on the same account with a growing delay, a smaller budget than the official client's (base delay 1 s doubling, set by `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: base delay in milliseconds, default 500, maximum 10000; `off` keeps only the retry of never-established connections); the account is re-checked before every retry, a `Retry-After` is honoured up to 15 seconds and passed to the client above that, and gateway verdicts (quota, balance, captcha, model, authentication), request errors and anything after output has started are never retried. On the ordered transport used for enforced client sessions, a failure after the request was fully written is not retried either. A native Anthropic stream that ends without `message_stop` receives one terminal `event: error` frame of type `api_error` (its message names `upstream_incomplete`, or `upstream_stream_error` when the read failed); an unfinished last frame is dropped so clients see a parseable failure instead of a silent truncation. ## Security diff --git a/zcode-proxy-src/README.zh-CN.md b/zcode-proxy-src/README.zh-CN.md index a7c13cb..d049273 100644 --- a/zcode-proxy-src/README.zh-CN.md +++ b/zcode-proxy-src/README.zh-CN.md @@ -43,7 +43,7 @@ node /zcode-proxy-src/captcha-compatibility.mjs [re 本地修复在解析转换流或 JSON 错误响应前解码 gzip、deflate 和 Brotli。空的或无法解码的批量响应会报告为 `upstream_invalid_response`,不会视为成功的空回复。代理不会将自身进程的目录冒充为调用方工作目录;`ZCODE_IDENTITY_ENV_CWD` 仍可用于显式覆盖。 -输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、带较短 `Retry-After` 的 429)会与官方客户端一样在同一账号上以递增等待最多重试三次;已识别的网关错误码、认证或模型错误以及输出开始后的任何故障都不会重试。没有 `message_stop` 就结束的原生 Anthropic 流会收到一个终止的 `event: error` 帧(`upstream_incomplete`,读取失败时为 `upstream_stream_error`),让客户端看到故障而不是无声截断。 +输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、429,以及官方客户端会重试的网关错误码)会在同一账号上以递增等待最多重试三次,预算比官方客户端小(基础等待 1 秒并翻倍,由 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 设置:基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试);每次重试前都会重新检查账号,`Retry-After` 最多遵守 15 秒,超过则交给客户端;网关裁定(配额、余额、验证码、模型、认证)、请求错误以及输出开始后的任何故障都不会重试。在用于强制客户端会话的有序传输上,请求完整写出之后的故障同样不会重试。没有 `message_stop` 就结束的原生 Anthropic 流会收到一个类型为 `api_error` 的终止 `event: error` 帧(消息注明 `upstream_incomplete`,读取失败时为 `upstream_stream_error`);未完成的最后一帧会被丢弃,让客户端看到可解析的故障而不是无声截断。 ## 安全 diff --git a/zcode-proxy-src/src/proxy/captcha-retry.ts b/zcode-proxy-src/src/proxy/captcha-retry.ts index b91ed42..3a16c30 100644 --- a/zcode-proxy-src/src/proxy/captcha-retry.ts +++ b/zcode-proxy-src/src/proxy/captcha-retry.ts @@ -21,6 +21,9 @@ import type * as CaptchaExports from "./captcha.js"; /** Shape the callers pass in — satisfied by the real captcha module or test fakes. */ export type CaptchaModuleLike = Pick; +/** Response header of the gateway's captcha challenge (the header variant); captcha.ts reuses it. */ +export const CAPTCHA_CHALLENGE_HEADER = "x-aliyun-captcha-verify-param"; + /** Magic strings of the in-body challenge, for both JSON spacing styles. */ export const IN_BODY_CHALLENGE_MARKERS = ['"code":3007', '"code": 3007'] as const; diff --git a/zcode-proxy-src/src/proxy/captcha.ts b/zcode-proxy-src/src/proxy/captcha.ts index 1740f49..166e7cf 100644 --- a/zcode-proxy-src/src/proxy/captcha.ts +++ b/zcode-proxy-src/src/proxy/captcha.ts @@ -25,6 +25,7 @@ import { urgentCaptchaRefill, type CaptchaConfig, } from "./captcha-pool.js"; +import { CAPTCHA_CHALLENGE_HEADER } from "./captcha-retry.js"; // /health must never import this module (it would start the solver machinery // on a plain health probe), so the module announces itself instead. @@ -44,7 +45,7 @@ registerCaptchaRuntime({ shutdown: () => shutdownCaptcha(), }); -const CAPTCHA_HEADER = "x-aliyun-captcha-verify-param"; +const CAPTCHA_HEADER = CAPTCHA_CHALLENGE_HEADER; const REGION_HEADER = "x-aliyun-captcha-verify-region"; const CONFIGS_API = "https://zcode.z.ai/api/v1/client/configs"; diff --git a/zcode-proxy-src/src/proxy/credential-recovery.test.ts b/zcode-proxy-src/src/proxy/credential-recovery.test.ts index 0ae6c90..baca917 100644 --- a/zcode-proxy-src/src/proxy/credential-recovery.test.ts +++ b/zcode-proxy-src/src/proxy/credential-recovery.test.ts @@ -54,7 +54,9 @@ for (const route of ["openai", "anthropic", "responses"]) describe(`${route} saf expect(resp.status).toBe(status); expect(await resp.text()).not.toContain("PRIVATE_FIXTURE"); expect(resp.headers.get("set-cookie")).toBeNull(); - if (status === 429) expect(resp.headers.get("retry-after")).toBe("7"); + // A bounded numeric Retry-After survives the mapping on the statuses + // clients read it for (429, 503, 529); a 500 carries none. + expect(resp.headers.get("retry-after")).toBe(status === 429 || status === 503 ? "7" : null); // 429/500/503 without a recognised gateway envelope are transient for // the pre-output ladder: retried on the same credential until the budget // is spent, then surfaced unchanged. 400/401/403 are request verdicts. diff --git a/zcode-proxy-src/src/proxy/handler.ts b/zcode-proxy-src/src/proxy/handler.ts index 9e92f1c..d2ef7c6 100644 --- a/zcode-proxy-src/src/proxy/handler.ts +++ b/zcode-proxy-src/src/proxy/handler.ts @@ -24,7 +24,7 @@ import { getDefaultClientSigning, sendWithClientSigning, type ClientSigningManag import { credentialString, type Credential } from "../auth/types.js"; import { isPostWriteError, sendOrderedUpstreamRequest } from "./ordered-transport.js"; import { transformRequestBody } from "./body-transformer.js"; -import { isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; +import { CAPTCHA_CHALLENGE_HEADER, isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; import { type ClientSessionResult } from "./client-session.js"; import { resolveSessionContext } from "./session-context.js"; import { gzipSync } from "node:zlib"; @@ -273,13 +273,16 @@ export async function proxyRequest( } } try { - // Only explicit pre-connect errors establish that a POST was not sent. - // Generic resets may happen after the upstream has already accepted it. - // Guard rails: skip retry when the client already aborted or the ordered - // transport flagged the failure postWrite; re-dispatch a FRESH Request - // each attempt — a reused Request has its body stream marked used after - // the first fetch (start-plan hits the plain pass-through path where - // dispatch does NOT rebuild the Request). + // Pre-output ladder: connect failures, drops before a response and + // transient gateway statuses/codes are re-dispatched on the same account + // (exact policy at dispatchWithConnectRetry). Guard rails: no retry once + // the client aborted, none after a failure the ordered transport flagged + // postWrite, the account is re-checked before every retry, and each + // attempt re-dispatches a FRESH Request — a reused Request has its body + // stream marked used after the first fetch (start-plan hits the plain + // pass-through path where dispatch does NOT rebuild the Request). On + // start-plan the retries carry the same pre-minted captcha token; if the + // gateway had already consumed it, the captcha layer below re-solves once. let dispatchAttempt = 0; upstreamResp = await dispatchWithConnectRetry( () => { @@ -292,6 +295,7 @@ export async function proxyRequest( { isAborted: () => clientReq.signal.aborted, signal: clientReq.signal, + beforeRetry: () => accountHandleStillCurrent(auth, accountHandle), onRetry: (attempt, reason, delayMs) => { if (debug) debugError(reqId, "upstream_transient_retry", `attempt ${attempt}/${MAX_TRANSIENT_ATTEMPTS} failed (${reason}), retrying in ${delayMs}ms`); console.log(`${reqId} upstream transient failure (${reason}), retry ${attempt + 1}/${MAX_TRANSIENT_ATTEMPTS} in ${delayMs}ms`); @@ -545,6 +549,20 @@ const DROP_ERROR_MESSAGES = [/socket hang up/i, /other side closed/i, /connectio const NON_TRANSIENT_CODE = /^(ERR_TLS_|ERR_SSL|CERT_|UNABLE_TO_|DEPTH_ZERO_|SELF_SIGNED)/; /** Gateway/edge statuses the official client retries as server errors. */ const TRANSIENT_STATUSES = new Set([500, 502, 503, 504, 524, 529]); +/** + * Gateway business codes the official client retries (its whitelist, from + * failure-provider-business-codes.ts of zcode 3.14.3): transient gateway + * conditions that arrive as a JSON envelope, with any HTTP status. + */ +export const RETRYABLE_GATEWAY_CODES = new Set([500, 1120, 1230, 1234, 1302, 1303, 1305, 1312, 2007, 3002]); +/** + * Verdicts about this request or account: quota/balance (their own retry + * schedule and rotation), captcha (its own re-solve), model, authorization, + * authentication and the thinking-config rejection. Never retried by the + * ladder, whatever the HTTP status — the official client treats them as + * terminal too. + */ +export const TERMINAL_GATEWAY_CODES = new Set([401, 1005, 1006, 1113, 1210, 3001, 3006, 3007, 3008, 3009, 3010, 3012]); type TransientKind = "connect" | "drop" | "status"; @@ -564,9 +582,10 @@ export function transientErrorKind(err: unknown): { kind: TransientKind; reason: if (isPostWriteError(err)) return null; const chain = errorChain(err); if (chain.some((e) => e.name === "AbortError" || e.code === "ABORT_ERR")) return null; + // A TLS/certificate code anywhere in the chain wins over a socket code that wraps it. + if (chain.some((e) => typeof e.code === "string" && NON_TRANSIENT_CODE.test(e.code))) return null; for (const e of chain) { if (typeof e.code !== "string") continue; - if (NON_TRANSIENT_CODE.test(e.code)) return null; if (CONNECT_ERROR_CODES.has(e.code)) return { kind: "connect", reason: e.code }; if (DROP_ERROR_CODES.has(e.code)) return { kind: "drop", reason: e.code }; } @@ -575,24 +594,47 @@ export function transientErrorKind(err: unknown): { kind: TransientKind; reason: return null; } -function retryAfterMs(resp: Response): number | null { - const raw = resp.headers.get("retry-after"); - if (!raw || !/^\d{1,6}$/.test(raw.trim())) return null; - return Number(raw.trim()) * 1000; +/** Retry-After as milliseconds: delta-seconds or an HTTP-date (never negative); null when absent or unparseable. */ +export function retryAfterMs(resp: Response, now: number = Date.now()): number | null { + const raw = resp.headers.get("retry-after")?.trim(); + if (!raw) return null; + if (/^\d{1,6}$/.test(raw)) return Number(raw) * 1000; + const at = Date.parse(raw); + return Number.isNaN(at) ? null : Math.max(0, at - now); } /** * Classify an upstream response; null = returned to the recovery layers as - * is. A recognised gateway business envelope (quota, auth, model, captcha) is - * a verdict about this request or account, never a transient fault, and - * streams are never inspected. + * is. Streams are never inspected, a captcha challenge belongs to the captcha + * layer whatever its status, and a gateway envelope decides by its code: a + * terminal verdict is never retried, a code the official client retries is + * retried with any HTTP status (the gateway also wraps errors in HTTP 200), + * any other code follows the HTTP status like a body-less response. */ -async function transientResponseReason(resp: Response): Promise { - if (resp.status !== 429 && !TRANSIENT_STATUSES.has(resp.status)) return null; - if (resp.headers.get("content-type")?.includes("text/event-stream")) return null; +export async function transientResponseReason(resp: Response): Promise { + const statusTransient = resp.status === 429 || TRANSIENT_STATUSES.has(resp.status); + if (!statusTransient && resp.status !== 200) return null; + if ((resp.headers.get("content-type") ?? "").toLowerCase().includes("text/event-stream")) return null; + if (resp.headers.get(CAPTCHA_CHALLENGE_HEADER)) return null; const envelope = await inspectGatewayEnvelope(resp); - if (envelope) return null; - return `HTTP ${resp.status}`; + if (envelope) { + const code = envelope.code ?? -1; + if (TERMINAL_GATEWAY_CODES.has(code)) return null; + if (RETRYABLE_GATEWAY_CODES.has(code)) return `gateway code ${code}`; + } + return statusTransient ? `HTTP ${resp.status}` : null; +} + +/** Admission re-check for the ladder: false when the pooled account is no longer current or cannot be verified. */ +export async function accountHandleStillCurrent(auth: AuthManager, handle: AccountHandle | undefined): Promise { + if (!handle) return true; + const validator = (auth as AuthManager & { validateAccountHandle?: (h: AccountHandle) => Promise }).validateAccountHandle; + if (typeof validator !== "function") return true; + try { + return (await validator.call(auth, handle)) === true; + } catch { + return false; + } } function transientDelayMs(kind: TransientKind, attempt: number, unitMs: number): number { @@ -618,12 +660,15 @@ async function abortableWait(ms: number, signal?: AbortSignal): Promise { * client and re-dispatches the identical request on the SAME account: * - thrown connect failures (never connected) and connection drops before * a response (reset, pipe, timeout) that are not flagged `postWrite`; - * - HTTP 500/502/503/504/524/529 and 429 whose body is not a recognised - * gateway business envelope; a numeric Retry-After is honoured up to - * TRANSIENT_RETRY_AFTER_CAP_MS, above it the response is surfaced. - * Never retried: business envelopes (quota, auth, model, captcha — their own - * layers handle them), request validation errors, TLS/certificate failures, - * anything after the client aborted, and errors flagged `postWrite`. + * - HTTP 500/502/503/504/524/529 and 429 whose body is not a terminal + * gateway verdict, and the gateway codes the official client retries + * (RETRYABLE_GATEWAY_CODES) with any status; a Retry-After (seconds or + * HTTP-date) is honoured up to TRANSIENT_RETRY_AFTER_CAP_MS, above it + * the response is surfaced. + * Never retried: terminal gateway verdicts (quota, balance, auth, model, + * captcha — their own layers handle them), captcha challenges, request + * validation errors, TLS/certificate failures, anything after the client + * aborted, and errors flagged `postWrite`. * * Contract (review P1/P2, PR #34/#35; transient extension after the * "Continue fixes it" report): @@ -632,6 +677,8 @@ async function abortableWait(ms: number, signal?: AbortSignal): Promise { * - failures flagged `postWrite` (ordered transport already wrote the full * request) are never retried — the upstream may have processed it. * - no retry once the client aborted (`opts.isAborted` / `opts.signal`). + * - `opts.beforeRetry` is consulted after every wait: false ends the ladder + * with the last outcome (response returned intact, error rethrown). * - the ladder never touches the sticky account, the quota-retry memo or * the captcha layer: those run on the response it returns. */ @@ -640,6 +687,8 @@ export async function dispatchWithConnectRetry( opts: { isAborted?: () => boolean; signal?: AbortSignal; + /** Re-check before every retry (account still current); false ends the ladder with the last outcome. */ + beforeRetry?: () => boolean | Promise; onRetry?: (attempt: number, reason: string, delayMs: number) => void; /** Test seam: base unit of the backoff (default 500 ms; 0 disables waiting). */ retryDelayMs?: number; @@ -647,6 +696,7 @@ export async function dispatchWithConnectRetry( ): Promise { const { unitMs, extended } = transientRetryPolicy(opts.retryDelayMs); const aborted = (): boolean => opts.isAborted?.() === true || opts.signal?.aborted === true; + const admitted = async (): Promise => (opts.beforeRetry ? (await opts.beforeRetry()) === true : true); for (let attempt = 1; ; attempt++) { if (aborted()) throw new Error("client aborted before upstream connect"); let resp: Response; @@ -658,6 +708,7 @@ export async function dispatchWithConnectRetry( const delayMs = transientDelayMs(transient.kind, attempt, unitMs); opts.onRetry?.(attempt, transient.reason, delayMs); await abortableWait(delayMs, opts.signal); + if (aborted() || !(await admitted())) throw err; continue; } if (!extended || attempt >= MAX_TRANSIENT_ATTEMPTS || aborted()) return resp; @@ -668,10 +719,16 @@ export async function dispatchWithConnectRetry( // Unit 0 means "retry without waiting" (tests, operators): it also skips a // short Retry-After; the cap above still surfaces a long one. const delayMs = unitMs === 0 ? 0 : retryAfter !== null ? retryAfter : transientDelayMs("status", attempt, unitMs); - void resp.body?.cancel().catch(() => {}); opts.onRetry?.(attempt, reason, delayMs); await abortableWait(delayMs, opts.signal); - if (aborted()) throw new Error("client aborted before upstream retry"); + // The response is released only once the retry is certain, so a refused + // retry hands the client an intact body. + if (aborted()) { + void resp.body?.cancel().catch(() => {}); + throw new Error("client aborted before upstream retry"); + } + if (!(await admitted())) return resp; + void resp.body?.cancel().catch(() => {}); } } @@ -853,22 +910,26 @@ function passthroughResponse( } const upstreamEncoding = headers.get("content-encoding")?.toLowerCase() ?? ""; + const codings = upstreamEncoding.split(",").map((c) => c.trim()).filter((c) => c && c !== "identity"); const source = body ?? upstream.body; - if (upstreamEncoding.includes("gzip") && !clientAcceptsGzip && source) { - const gunzip = new DecompressionStream("gzip") as unknown as ReadableWritablePair; - const decompressed = source.pipeThrough(gunzip); + // Decode here when the client cannot take gzip, or when a monitor must see + // plain bytes (the terminal-frame guarantee for native streams): the client + // then gets an identity-encoded body without the now-mismatched headers. + const decodeForClient = codings.some((c) => c === "gzip" || c === "x-gzip") && !clientAcceptsGzip; + if (source && codings.length && codings.every((c) => AUTO_DECODED_ENCODINGS.has(c)) && (decodeForClient || monitor)) { + const decoded = decodeContentStream(source, upstreamEncoding); headers.delete("content-encoding"); headers.delete("content-length"); - return new Response(monitor ? monitor(decompressed) : decompressed, { + return new Response(monitor ? monitor(decoded) : decoded, { status: upstream.status, statusText: upstream.statusText, headers, }); } - // A compressed body forwarded as-is cannot be inspected; the monitor only - // sees plain bytes. - const clientBody = source && monitor && !upstreamEncoding ? monitor(source) : source; + // A body in a coding this proxy cannot decode is forwarded as is and cannot + // be monitored; the monitor only ever sees plain bytes. + const clientBody = source && monitor && !codings.length ? monitor(source) : source; return new Response(clientBody, { status: upstream.status, statusText: upstream.statusText, diff --git a/zcode-proxy-src/src/proxy/responses-handler.ts b/zcode-proxy-src/src/proxy/responses-handler.ts index 4b016c1..5e32b80 100644 --- a/zcode-proxy-src/src/proxy/responses-handler.ts +++ b/zcode-proxy-src/src/proxy/responses-handler.ts @@ -24,7 +24,7 @@ import type { AuthManager } from "../auth/manager.js"; import type { AccountHandle } from "../auth/account-rotator.js"; import { buildUpstreamRequest, buildUpstreamHeaderPairs, type UpstreamHeaderPair } from "./upstream.js"; import { isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; -import { dispatchWithConnectRetry } from "./handler.js"; +import { accountHandleStillCurrent, dispatchWithConnectRetry } from "./handler.js"; import { recoverAndMapUpstream } from "./upstream-errors.js"; import type * as CaptchaExports from "./captcha.js"; @@ -264,6 +264,7 @@ export async function handleResponses( upstreamResp = await dispatchWithConnectRetry(() => dispatch(upstreamHeaders), { isAborted: () => clientReq.signal.aborted, signal: clientReq.signal, + beforeRetry: () => accountHandleStillCurrent(opts.auth, accountHandle), onRetry: (attempt, reason, delayMs) => { console.log(`[responses] upstream transient failure (${reason}), retry ${attempt + 1} in ${delayMs}ms`); }, diff --git a/zcode-proxy-src/src/proxy/sse-terminal.test.ts b/zcode-proxy-src/src/proxy/sse-terminal.test.ts index 603b3ea..ff833c7 100644 --- a/zcode-proxy-src/src/proxy/sse-terminal.test.ts +++ b/zcode-proxy-src/src/proxy/sse-terminal.test.ts @@ -1,24 +1,30 @@ /** * Terminal-event guarantee for natively passed-through Anthropic streams: - * bytes are forwarded unchanged; exactly one `event: error` frame is appended - * when the upstream ends without message_stop (or the read fails), never a - * fabricated message_stop, never a second terminal event. + * complete frames are forwarded byte-exactly, an unfinished last frame is + * dropped, and exactly one `event: error` frame is appended when the upstream + * ends without message_stop (or the read fails) — never a fabricated + * message_stop, never a second terminal event. */ import { describe, it, expect } from "bun:test"; -import { ANTHROPIC_INCOMPLETE_EVENT, ANTHROPIC_STREAM_ERROR_EVENT, ensureAnthropicSseTerminal } from "./sse-terminal.js"; +import { + ANTHROPIC_INCOMPLETE_EVENT, ANTHROPIC_STREAM_ERROR_EVENT, MAX_PENDING_FRAME_BYTES, ensureAnthropicSseTerminal, lastFrameEnd, +} from "./sse-terminal.js"; const encoder = new TextEncoder(); const START = 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n'; const DELTA = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}\n\n'; const STOP = 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; const UPSTREAM_ERROR = 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n'; +const CUT = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel'; +const crlf = (s: string): string => s.replace(/\n/g, "\r\n"); -function streamOf(chunks: string[], opts: { failAfter?: boolean; onCancel?: () => void } = {}): ReadableStream { +function streamOf(chunks: Array, opts: { failAfter?: boolean; onCancel?: () => void } = {}): ReadableStream { let index = 0; return new ReadableStream({ pull(controller) { if (index < chunks.length) { - controller.enqueue(encoder.encode(chunks[index++])); + const chunk = chunks[index++]; + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk); return; } if (opts.failAfter) controller.error(new Error("upstream socket reset")); @@ -28,14 +34,25 @@ function streamOf(chunks: string[], opts: { failAfter?: boolean; onCancel?: () = }); } -async function collect(stream: ReadableStream): Promise { +async function collectBytes(stream: ReadableStream): Promise { const reader = stream.getReader(); - const decoder = new TextDecoder(); - let out = ""; + const parts: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); - if (done) return out + decoder.decode(); - out += decoder.decode(value, { stream: true }); + if (done) return Buffer.concat(parts); + parts.push(value); + } +} +async function collect(stream: ReadableStream): Promise { + return new TextDecoder().decode(await collectBytes(stream)); +} +/** Every frame of `text` is an event line plus one JSON data line. */ +function expectParseableFrames(text: string): void { + for (const frame of text.split("\n\n").filter(Boolean)) { + const [eventLine, dataLine, ...rest] = frame.split("\n"); + expect(rest).toEqual([]); + expect(eventLine.startsWith("event: ")).toBe(true); + expect(() => JSON.parse(dataLine.replace(/^data: /, ""))).not.toThrow(); } } @@ -67,6 +84,53 @@ describe("ensureAnthropicSseTerminal", () => { expect(text).not.toContain("event: message_stop"); }); + it("drops an unfinished last frame so the error frame stays parseable (mid-frame cut)", async () => { + const text = await collect(ensureAnthropicSseTerminal(streamOf([START, DELTA, CUT]))); + expect(text).toBe(START + DELTA + ANTHROPIC_INCOMPLETE_EVENT); + expectParseableFrames(text); + const failed = await collect(ensureAnthropicSseTerminal(streamOf([START, CUT], { failAfter: true }))); + expect(failed).toBe(START + ANTHROPIC_STREAM_ERROR_EVENT); + expectParseableFrames(failed); + }); + + it("a message_stop line whose frame never completed is not a terminal event", async () => { + expect(await collect(ensureAnthropicSseTerminal(streamOf([START, "event: message_stop\n"])))).toBe(START + ANTHROPIC_INCOMPLETE_EVENT); + expect(await collect(ensureAnthropicSseTerminal(streamOf([START, 'event: message_stop\ndata: {"type":"message_stop"}\n'])))).toBe(START + ANTHROPIC_INCOMPLETE_EVENT); + }); + + it("forwards frames byte-exactly even when chunks split a frame or a multi-byte character", async () => { + const delta = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Héllo"}}\n\n'; + const bytes = encoder.encode(START + delta + STOP); + const cut = START.length + delta.indexOf("é") + 1; // between the two bytes of é + const chunks = [bytes.subarray(0, 5), bytes.subarray(5, cut), bytes.subarray(cut, cut + 7), bytes.subarray(cut + 7)]; + const out = await collectBytes(ensureAnthropicSseTerminal(streamOf(chunks))); + expect(Buffer.from(out).equals(Buffer.from(bytes))).toBe(true); + }); + + it("recognises CRLF frames: no second terminal frame, none after a CRLF message_stop, one after a CRLF cut", async () => { + expect(await collect(ensureAnthropicSseTerminal(streamOf([crlf(START), crlf(STOP)])))).toBe(crlf(START) + crlf(STOP)); + expect(await collect(ensureAnthropicSseTerminal(streamOf([crlf(START), crlf(UPSTREAM_ERROR)])))).toBe(crlf(START) + crlf(UPSTREAM_ERROR)); + expect(await collect(ensureAnthropicSseTerminal(streamOf([crlf(START), crlf(DELTA), crlf(CUT)])))).toBe(crlf(START) + crlf(DELTA) + ANTHROPIC_INCOMPLETE_EVENT); + }); + + it("lastFrameEnd finds the last blank line for LF, CRLF and CR endings and leaves a trailing CR pending", () => { + const at = (s: string): number => lastFrameEnd(encoder.encode(s)); + expect(at("a\n\nb")).toBe(3); + expect(at("a\r\n\r\nb\r\n")).toBe(5); + expect(at("a\n\r\n")).toBe(4); + expect(at("a\r\rb")).toBe(3); + expect(at("a\nb\n")).toBe(0); + expect(at("a\n\r")).toBe(0); + expect(at("x\n\ny\n\nz")).toBe(6); + expect(at("")).toBe(0); + }); + + it("forwards an oversized unframed chunk and still closes it before the error frame", async () => { + const big = "x".repeat(MAX_PENDING_FRAME_BYTES + 1); + const text = await collect(ensureAnthropicSseTerminal(streamOf([START, big]))); + expect(text).toBe(START + big + "\n\n" + ANTHROPIC_INCOMPLETE_EVENT); + }); + it("an empty upstream body still gets a terminal error frame", async () => { expect(await collect(ensureAnthropicSseTerminal(streamOf([])))).toBe(ANTHROPIC_INCOMPLETE_EVENT); }); @@ -80,14 +144,14 @@ describe("ensureAnthropicSseTerminal", () => { expect(cancelled).toBe(true); }); - it("the error frames are valid Anthropic error events", () => { - for (const frame of [ANTHROPIC_INCOMPLETE_EVENT, ANTHROPIC_STREAM_ERROR_EVENT]) { + it("the error frames are valid Anthropic error events of the standard api_error type", () => { + for (const [frame, cause] of [[ANTHROPIC_INCOMPLETE_EVENT, "upstream_incomplete"], [ANTHROPIC_STREAM_ERROR_EVENT, "upstream_stream_error"]]) { const [eventLine, dataLine] = frame.trim().split("\n"); expect(eventLine).toBe("event: error"); const data = JSON.parse(dataLine.replace(/^data: /, "")); expect(data.type).toBe("error"); - expect(typeof data.error.type).toBe("string"); - expect(typeof data.error.message).toBe("string"); + expect(data.error.type).toBe("api_error"); + expect(data.error.message).toContain(cause); } }); }); diff --git a/zcode-proxy-src/src/proxy/sse-terminal.ts b/zcode-proxy-src/src/proxy/sse-terminal.ts index 6148980..f46e6fd 100644 --- a/zcode-proxy-src/src/proxy/sse-terminal.ts +++ b/zcode-proxy-src/src/proxy/sse-terminal.ts @@ -3,61 +3,122 @@ * through natively (no translation). The gateway sometimes closes a stream * without `message_stop`, or the upstream read fails mid-stream. Without a * terminal event, Anthropic-format clients (OMP, Claude Code) see a silently - * truncated turn; with the documented `event: error` frame they can report - * or recover from the failure. Bytes already forwarded are never replayed - * and no `message_stop` is ever fabricated — only one well-formed error - * frame is appended when the stream ends without any terminal event. + * truncated turn; with a well-formed `event: error` frame they can report + * the failure instead. + * + * Bytes are forwarded frame by frame: a complete SSE frame goes out unchanged + * as soon as its closing blank line arrived, and an unfinished last frame + * (the usual shape of a cut connection: `…"text":"Hel`) is dropped so that + * the appended error frame stays parseable — a client could not have used + * the partial frame anyway. Nothing already forwarded is ever replayed and no + * `message_stop` is ever fabricated. */ +/** Standard Anthropic error type; the cause is named in the message. */ export const ANTHROPIC_INCOMPLETE_EVENT = - 'event: error\ndata: {"type":"error","error":{"type":"upstream_incomplete","message":"Upstream stream ended before message_stop"}}\n\n'; + 'event: error\ndata: {"type":"error","error":{"type":"api_error","message":"Upstream stream ended before message_stop (upstream_incomplete)"}}\n\n'; export const ANTHROPIC_STREAM_ERROR_EVENT = - 'event: error\ndata: {"type":"error","error":{"type":"upstream_stream_error","message":"Upstream stream failed before completion"}}\n\n'; + 'event: error\ndata: {"type":"error","error":{"type":"api_error","message":"Upstream stream failed before completion (upstream_stream_error)"}}\n\n'; -const TERMINAL_EVENT_LINE = /^event:[ \t]*(message_stop|error)[ \t]*$/m; -const TAIL_KEEP = 128; +/** With the m flag `$` matches before LF and CR alike, so CRLF frames are covered. */ +const TERMINAL_EVENT_LINE = /^event:[ \t]*(message_stop|error)[ \t]*\r?$/m; +/** + * A frame that has not ended after this many bytes is forwarded unframed + * (memory bound). The guarantee then degrades to a closing blank line before + * the error frame; Anthropic frames are far smaller than this. + */ +export const MAX_PENDING_FRAME_BYTES = 1 << 20; +const EMPTY = new Uint8Array(0); + +/** + * End (exclusive) of the last complete SSE frame in `buf`, or 0. A frame ends + * with an empty line; a line ends with CRLF, LF or CR. A trailing lone CR is + * left pending because it may be the first half of a CRLF. + */ +export function lastFrameEnd(buf: Uint8Array): number { + let end = 0; + let lineJustEnded = false; + for (let i = 0; i < buf.length; i += 1) { + const b = buf[i]; + if (b === 0x0d) { + if (i + 1 >= buf.length) break; + if (buf[i + 1] === 0x0a) i += 1; + if (lineJustEnded) end = i + 1; + lineJustEnded = true; + } else if (b === 0x0a) { + if (lineJustEnded) end = i + 1; + lineJustEnded = true; + } else { + lineJustEnded = false; + } + } + return end; +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + if (a.length === 0) return b; + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} /** - * Forward `source` unchanged and append one terminal `event: error` frame if - * the stream ends (EOF or read failure) before a `message_stop` or `error` - * event was seen. A client cancel is propagated to the source. + * Forward `source` frame by frame and append one terminal `event: error` + * frame if the stream ends (EOF or read failure) before a `message_stop` or + * `error` event was seen. A client cancel is propagated to the source. */ export function ensureAnthropicSseTerminal(source: ReadableStream): ReadableStream { const reader = source.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); - let tail = ""; + let pending: Uint8Array = EMPTY; let terminalSeen = false; + let framed = true; // the last forwarded byte closed a frame let cancelled = false; - const observe = (chunk: Uint8Array): void => { - if (terminalSeen) return; - const text = tail + decoder.decode(chunk, { stream: true }); - if (TERMINAL_EVENT_LINE.test(text)) terminalSeen = true; - tail = text.length > TAIL_KEEP ? text.slice(-TAIL_KEEP) : text; + const forward = (controller: ReadableStreamDefaultController, bytes: Uint8Array, atFrameEnd: boolean): void => { + if (!terminalSeen && TERMINAL_EVENT_LINE.test(decoder.decode(bytes, { stream: true }))) terminalSeen = true; + framed = atFrameEnd; + controller.enqueue(bytes); + }; + const finish = (controller: ReadableStreamDefaultController, frame: string): void => { + if (cancelled) return; + // An unfinished last frame (still in `pending`) is dropped: the client + // could not parse it, and the error frame below must stay well-formed. + if (!terminalSeen) controller.enqueue(encoder.encode((framed ? "" : "\n\n") + frame)); + controller.close(); }; return new ReadableStream({ async pull(controller) { - let result: Awaited>; - try { - result = await reader.read(); - } catch { - if (!cancelled) { - if (!terminalSeen) controller.enqueue(encoder.encode(ANTHROPIC_STREAM_ERROR_EVENT)); - controller.close(); + for (;;) { + let result: Awaited>; + try { + result = await reader.read(); + } catch { + finish(controller, ANTHROPIC_STREAM_ERROR_EVENT); + return; } - return; - } - if (result.done) { - if (!cancelled) { - if (!terminalSeen) controller.enqueue(encoder.encode(ANTHROPIC_INCOMPLETE_EVENT)); - controller.close(); + if (result.done) { + finish(controller, ANTHROPIC_INCOMPLETE_EVENT); + return; + } + pending = concat(pending, result.value); + const end = lastFrameEnd(pending); + if (end > 0) { + const complete = pending.subarray(0, end); + pending = end < pending.length ? pending.slice(end) : EMPTY; + forward(controller, complete, true); + return; + } + if (pending.length > MAX_PENDING_FRAME_BYTES) { + const unframed = pending; + pending = EMPTY; + forward(controller, unframed, false); + return; } - return; } - observe(result.value); - controller.enqueue(result.value); }, cancel(reason) { cancelled = true; diff --git a/zcode-proxy-src/src/proxy/transient-retry.test.ts b/zcode-proxy-src/src/proxy/transient-retry.test.ts index 9036c63..d44becd 100644 --- a/zcode-proxy-src/src/proxy/transient-retry.test.ts +++ b/zcode-proxy-src/src/proxy/transient-retry.test.ts @@ -6,8 +6,9 @@ */ import { describe, it, expect } from "bun:test"; import { - dispatchWithConnectRetry, MAX_TRANSIENT_ATTEMPTS, TRANSIENT_RETRY_AFTER_CAP_MS, transientErrorKind, transientRetryPolicy, + dispatchWithConnectRetry, MAX_TRANSIENT_ATTEMPTS, TRANSIENT_RETRY_AFTER_CAP_MS, retryAfterMs, transientErrorKind, transientRetryPolicy, } from "./handler.js"; +import { CAPTCHA_CHALLENGE_HEADER } from "./captcha-retry.js"; describe("ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS", () => { const previous = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; @@ -95,21 +96,95 @@ describe("transient pre-output retry ladder", () => { expect(await resp.text()).toContain("edge error"); // the surfaced response is still readable }); - it("never retries a recognised gateway business envelope, whatever the HTTP status", async () => { + it("never retries a terminal gateway verdict, whatever the HTTP status", async () => { for (const [status, body] of [ [500, { code: 1005, msg: "exceed quota limit" }], [503, { code: 3007, msg: "captcha verify failed" }], [502, { code: 3001, msg: "rejected" }], - [429, { code: 429, msg: "rate limited" }], + [429, { code: 1113, msg: "Insufficient balance" }], + [529, { code: 3006, msg: "model not allowed" }], + [200, { code: 1005, msg: "exceed quota limit" }], + [503, { type: "error", error: { type: "invalid_request_error", message: "[1210] thinking" } }], ] as const) { let calls = 0; const resp = await dispatchWithConnectRetry(async () => { calls += 1; return json(status, body); }, { retryDelayMs: 0 }); expect(calls).toBe(1); expect(resp.status).toBe(status); - expect((await resp.json()).code).toBe(body.code); // the body was inspected without being consumed + expect(JSON.stringify(await resp.json())).toBe(JSON.stringify(body)); // the body was inspected without being consumed } }); + it("retries the gateway codes the official client retries with any status; other codes follow the HTTP status", async () => { + for (const [status, code] of [[200, 1302], [500, 1234], [429, 1305], [200, 3002], [503, 2007]] as const) { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return calls < 2 ? json(status, { code, msg: "PRIVATE" }) : new Response("ok"); }, { retryDelayMs: 0 }); + expect(resp.status).toBe(200); + expect(calls).toBe(2); + } + let calls = 0; + const unknownOn200 = await dispatchWithConnectRetry(async () => { calls += 1; return json(200, { code: 4242, msg: "?" }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect((await unknownOn200.json()).code).toBe(4242); + calls = 0; + const unknownOn503 = await dispatchWithConnectRetry(async () => { calls += 1; return calls < 2 ? json(503, { code: 4242 }) : new Response("ok"); }, { retryDelayMs: 0 }); + expect(unknownOn503.status).toBe(200); + expect(calls).toBe(2); + calls = 0; + const rateLimited = await dispatchWithConnectRetry(async () => { calls += 1; return calls < 2 ? json(429, { code: 429, msg: "rate limited" }) : new Response("ok"); }, { retryDelayMs: 0 }); + expect(rateLimited.status).toBe(200); + expect(calls).toBe(2); + calls = 0; + const plain200 = await dispatchWithConnectRetry(async () => { calls += 1; return json(200, { id: "msg", type: "message", content: [] }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect((await plain200.json()).id).toBe("msg"); // a real message is never inspected away + }); + + it("hands a captcha challenge to the captcha layer even on a transient status", async () => { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return html(503, { [CAPTCHA_CHALLENGE_HEADER]: "challenge" }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(resp.status).toBe(503); + }); + + it("reads an HTTP-date Retry-After, treats an unparseable one as absent and surfaces a far one", async () => { + const soon = new Date(Date.now() + 1000).toUTCString(); + const soonMs = retryAfterMs(new Response("", { headers: { "retry-after": soon } })); + expect(soonMs).toBeGreaterThan(0); + expect(soonMs).toBeLessThanOrEqual(1000); + expect(retryAfterMs(new Response("", { headers: { "retry-after": new Date(Date.now() - 60_000).toUTCString() } }))).toBe(0); + expect(retryAfterMs(new Response("", { headers: { "retry-after": "soonish" } }))).toBeNull(); + expect(retryAfterMs(new Response(""))).toBeNull(); + expect(retryAfterMs(new Response("", { headers: { "retry-after": " 3 " } }))).toBe(3000); + let calls = 0; + const far = new Date(Date.now() + TRANSIENT_RETRY_AFTER_CAP_MS + 60_000).toUTCString(); + const surfaced = await dispatchWithConnectRetry(async () => { calls += 1; return html(503, { "retry-after": far }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + expect(surfaced.status).toBe(503); + }); + + it("stops when the account is no longer admitted and hands back the last outcome intact", async () => { + let calls = 0; + const resp = await dispatchWithConnectRetry(async () => { calls += 1; return html(503); }, { retryDelayMs: 0, beforeRetry: () => false }); + expect(calls).toBe(1); + expect(resp.status).toBe(503); + expect(await resp.text()).toContain("edge error"); // the refused retry did not release the body + calls = 0; + await expect(dispatchWithConnectRetry(async () => { calls += 1; throw Object.assign(new Error("reset"), { code: "ECONNRESET" }); }, { retryDelayMs: 0, beforeRetry: async () => false })).rejects.toThrow(/reset/); + expect(calls).toBe(1); + calls = 0; + let checks = 0; + const ok = await dispatchWithConnectRetry(async () => { calls += 1; return calls < 3 ? html(502) : new Response("ok"); }, { retryDelayMs: 0, beforeRetry: () => { checks += 1; return true; } }); + expect(ok.status).toBe(200); + expect(calls).toBe(3); + expect(checks).toBe(2); // once before every retry + }); + + it("a case variant of text/event-stream is still a stream", async () => { + let calls = 0; + await dispatchWithConnectRetry(async () => { calls += 1; return new Response("event: error\n\n", { status: 503, headers: { "content-type": "Text/Event-Stream; charset=utf-8" } }); }, { retryDelayMs: 0 }); + expect(calls).toBe(1); + }); + it("does not retry request-level errors", async () => { for (const status of [400, 401, 403, 404, 409, 422]) { let calls = 0; @@ -177,6 +252,7 @@ describe("transient pre-output retry ladder", () => { expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNRESET", postWrite: true }))).toBeNull(); expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("y"), { postWrite: true }) }))).toBeNull(); expect(transientErrorKind(Object.assign(new Error("cert"), { code: "CERT_HAS_EXPIRED" }))).toBeNull(); + expect(transientErrorKind(Object.assign(new Error("socket"), { code: "UND_ERR_SOCKET", cause: Object.assign(new Error("cert"), { code: "CERT_HAS_EXPIRED" }) }))).toBeNull(); // TLS anywhere in the chain wins expect(transientErrorKind(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBeNull(); expect(transientErrorKind(new Error("something else"))).toBeNull(); expect(transientErrorKind(null)).toBeNull(); diff --git a/zcode-proxy-src/src/proxy/upstream-errors.ts b/zcode-proxy-src/src/proxy/upstream-errors.ts index 66b4c78..7f43d86 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors.ts @@ -105,7 +105,7 @@ export async function inspectGatewayEnvelope(resp: Response): Promise<{ status: } async function inspect(resp: Response): Promise<{ status: number; type: string; message: string; code?: number; resetAt?: number } | null> { - if (resp.headers.get("content-type")?.includes("text/event-stream")) return null; + if ((resp.headers.get("content-type") ?? "").toLowerCase().includes("text/event-stream")) return null; try { const copy = resp.clone(); if (!copy.body) return null; @@ -279,9 +279,11 @@ export async function recoverAndMapUpstream(opts: { const type = status === 401 ? "authentication_error" : status === 403 ? "permission_error" : status === 429 ? "rate_limit_error" : envelope?.code === 1210 || response.ok ? envelope?.type ?? "upstream_error" : "upstream_error"; const message = envelope?.message ?? `Upstream request failed (HTTP ${status}).`; const result = Response.json({ error: { type, message } }, { status }); - // Preserve retry guidance only when it is a bounded numeric delta, not arbitrary upstream data. - const retryAfter = response.headers.get("retry-after"); - if (status === 429 && retryAfter && /^\d{1,6}$/.test(retryAfter)) result.headers.set("retry-after", retryAfter); + // Preserve retry guidance only when it is a bounded numeric delta, not + // arbitrary upstream data — on the statuses clients read it for (429, 503, + // 529), including a delay the transient ladder surfaced instead of waiting. + const retryAfter = response.headers.get("retry-after")?.trim(); + if ([429, 503, 529].includes(status) && retryAfter && /^\d{1,6}$/.test(retryAfter)) result.headers.set("retry-after", retryAfter); const requestId = response.headers.get('x-request-id'); if (requestId && /^[A-Za-z0-9._:-]{1,128}$/.test(requestId)) result.headers.set('x-request-id', requestId); void response.body?.cancel().catch(() => {}); diff --git a/zcode-proxy-src/src/proxy/upstream.test.ts b/zcode-proxy-src/src/proxy/upstream.test.ts index 529f6de..cf3a4a7 100644 --- a/zcode-proxy-src/src/proxy/upstream.test.ts +++ b/zcode-proxy-src/src/proxy/upstream.test.ts @@ -563,12 +563,10 @@ describe("proxyRequest", () => { }); it("appends one terminal error event when the native Anthropic stream ends before message_stop", async () => { - const truncated = [ - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-4.6","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}', - '', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}', - '', - ].join("\n"); + const { ANTHROPIC_INCOMPLETE_EVENT } = await import("./sse-terminal.js"); + const truncated = + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-4.6","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}\n\n' + + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}\n\n'; const fetchMock = mock(async (): Promise => new Response(truncated, { status: 200, headers: { "content-type": "text/event-stream" } })); const auth = oauthAuth(); const clientReq = makeClientReq('{"model":"glm-4.6","messages":[],"stream":true}'); @@ -577,11 +575,31 @@ describe("proxyRequest", () => { expect(resp.status).toBe(200); const text = await resp.text(); expect(fetchMock).toHaveBeenCalledTimes(1); // no replay after bytes were forwarded - expect(text.startsWith(truncated)).toBe(true); - expect(text.endsWith('event: error\ndata: {"type":"error","error":{"type":"upstream_incomplete","message":"Upstream stream ended before message_stop"}}\n\n')).toBe(true); + expect(text).toBe(truncated + ANTHROPIC_INCOMPLETE_EVENT); expect(text).not.toContain("event: message_stop"); // no fabricated stop event }); + it("keeps the terminal guarantee for a gzip-compressed native stream by forwarding it decoded, dropping a cut frame", async () => { + const { ANTHROPIC_INCOMPLETE_EVENT } = await import("./sse-terminal.js"); + const { gzipSync } = await import("node:zlib"); + const complete = + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n' + + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}\n\n'; + const cut = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel'; + const fetchMock = mock(async (): Promise => new Response(gzipSync(Buffer.from(complete + cut)), { + status: 200, headers: { "content-type": "text/event-stream", "content-encoding": "gzip" }, + })); + const auth = oauthAuth(); + const clientReq = makeClientReq('{"model":"glm-4.6","messages":[],"stream":true}', { "accept-encoding": "gzip" }); + + const resp = await proxyRequest(clientReq, "anthropic", { config: testConfig, auth, fetchImpl: fetchMock as any }); + expect(resp.status).toBe(200); + expect(resp.headers.get("content-encoding")).toBeNull(); // decoded so the frames could be watched + const text = await resp.text(); + expect(text).toBe(complete + ANTHROPIC_INCOMPLETE_EVENT); + for (const frame of text.split("\n\n").filter(Boolean)) expect(() => JSON.parse(frame.split("\ndata: ")[1])).not.toThrow(); + }); + it("passes the Anthropic batch response through with raw-decompress transport", async () => { const fetchMock = mock(async (_req: Request, init?: RequestInit & { decompress?: boolean }): Promise => { expect(init?.decompress).toBe(false); From a7a81031c6badcfe7ea32daef6132ca690bf237c Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 17:40:14 +0000 Subject: [PATCH 5/6] proxy: retry streams that fail before output, keep unsaved account state; kit: status --json, setup --select, doctor --forget/--upstream, rsync-free update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy - Stream prelude retry: a 200 event stream is read until its first content event. A transient error event (overloaded, api/rate-limit error, a retryable gateway code) or an end/read failure in that window is retried by the pre-output ladder on the same account, as the official client does; a content event hands on the held prelude and the untouched rest. Data-only frames are classified by their JSON type like the translators; bodies the transport already inflated are read as they are; a client abort cancels the upstream at once; bounds 15 s per attempt and 256 KiB held. Gateway codes shared in gateway-codes.ts. - Start-plan: a retry after a response, a post-write drop or a failed prelude takes a fresh pooled captcha token; a never-connected attempt keeps it; a mint failure keeps the previous one. - Ordered transport parity: a failure after the request was written and before any response is a drop, re-sent on the same account like a reset on the fetch path; the quota failover still never replays it on another account; a client abort is an AbortError and never retried. - Account metadata persistence: a failed write (store lock held by another process, transient I/O) stays unsaved and is rewritten in the background after 0.25/1/4/15 s instead of being dropped; each new change restarts the schedule, a busy 3-pass cap continues in the background, SIGINT/SIGTERM and the memory restart make one bounded final write; `accounts doctor` warns runtime_changes_unsaved. Kit - `proxy status --json` / `status --json`: one object (health, pid, base URLs, routes, model ids and their source, quota); never the key. - `setup --select`: one numbered list of detected harnesses (numbers, all, none); chosen = configured, the other detected = skipped; MCP only after the note and without --no-mcp; ignored next to an explicit selection or without a terminal. - doctor: unreadable decision files FAIL with the way out, decisions for undetected or unknown harnesses SKIP; `doctor --forget ` removes one decision through a transaction (integration untouched). - `doctor --upstream` (opt-in): compares the kit's gateway with the provider configuration the ZCode client receives (remote release or legacy provider list; reference client 3.14.3 when the configured version gets no plan data); no credentials, no redirects, one 8 s budget. - install.sh: repeat installs no longer need rsync; a portable tar/find mirror copies first, then removes what the release dropped, never touching the key, user config, Bun path, node_modules, backups, logs or generated (at any depth). Tests: stream-prelude, persistence retry, prelude/captcha integration, abort during the prelude, status --json, --select (pty), --forget with rollback, doctor --upstream (both shapes, boundaries, drift against the proxy constants), repeat install (portable and default), and Windows CI tests for install.ps1 repeat install and setup exit 20/130/1. Docs (en + de/es/ja/zh-CN). Reviews before this commit: protocol review of the proxy part (double decompression in translation mode, data-only frames, shutdown write, schedule re-arm, byte bound — fixed) and a test-matrix review of the kit part (one time budget for both upstream requests — fixed). --- README.de.md | 6 +- README.es.md | 6 +- README.ja.md | 6 +- README.md | 6 +- README.zh-CN.md | 6 +- cli/connection-details.mjs | 30 ++- cli/harness-consent.mjs | 94 ++++++- cli/upstream-config.mjs | 179 ++++++++++++ cli/zcode-kit.mjs | 117 +++++++- docs/ACCOUNT_ROTATOR.de.md | 8 + docs/ACCOUNT_ROTATOR.md | 7 + harnesses/README.de.md | 1 + harnesses/README.es.md | 1 + harnesses/README.ja.md | 1 + harnesses/README.md | 1 + harnesses/README.zh-CN.md | 1 + install.sh | 47 +++- proxy/zcode-proxy-manager.mjs | 68 ++++- tests/connection-details.test.mjs | 44 +++ tests/harness-consent.test.mjs | 80 +++++- tests/installer-audit.test.mjs | 183 ++++++++++++- tests/upstream-config.test.mjs | 163 +++++++++++ zcode-proxy-src/README.de.md | 2 +- zcode-proxy-src/README.es.md | 2 +- zcode-proxy-src/README.ja.md | 2 +- zcode-proxy-src/README.md | 2 +- zcode-proxy-src/README.zh-CN.md | 2 +- zcode-proxy-src/src/auth/account-pool.test.ts | 114 ++++++++ zcode-proxy-src/src/auth/manager.ts | 149 ++++++++-- zcode-proxy-src/src/index.ts | 46 +++- zcode-proxy-src/src/proxy/gateway-codes.ts | 17 ++ .../src/proxy/handler-resilience.test.ts | 125 ++++++++- zcode-proxy-src/src/proxy/handler.ts | 112 +++++--- .../src/proxy/ordered-transport.test.ts | 7 +- .../src/proxy/ordered-transport.ts | 21 +- .../src/proxy/responses-handler.test.ts | 8 +- .../src/proxy/responses-handler.ts | 23 +- .../src/proxy/stream-prelude.test.ts | 207 ++++++++++++++ zcode-proxy-src/src/proxy/stream-prelude.ts | 254 ++++++++++++++++++ .../src/proxy/transient-retry.test.ts | 7 +- zcode-proxy-src/src/proxy/upstream-errors.ts | 7 +- .../src/server/protocol-contract.test.ts | 42 ++- 42 files changed, 2059 insertions(+), 145 deletions(-) create mode 100644 cli/upstream-config.mjs create mode 100644 tests/upstream-config.test.mjs create mode 100644 zcode-proxy-src/src/proxy/gateway-codes.ts create mode 100644 zcode-proxy-src/src/proxy/stream-prelude.test.ts create mode 100644 zcode-proxy-src/src/proxy/stream-prelude.ts diff --git a/README.de.md b/README.de.md index 3593348..2354c52 100644 --- a/README.de.md +++ b/README.de.md @@ -47,7 +47,7 @@ Der Aufruf funktioniert aus jedem Verzeichnis. Du musst das Repository nicht klo Standardorte: `%LOCALAPPDATA%\zcode-agent-kit` unter Windows; `$HOME/.local/share/zcode-agent-kit` unter macOS/Linux. Halte dieses Verzeichnis von deinen Projekten getrennt. Gib niemals dein Home-Verzeichnis oder einen Quellcode-Checkout als Installationsziel an: Updates ersetzen Dateien im Zielverzeichnis. -macOS/Linux benötigen außerdem `curl`, `tar` und ein SHA-256-Werkzeug; zum Einrichten von Bun ist `unzip` nötig, für Updates `rsync`. Die aktuelle Prüfung mit realen Clients konzentriert sich auf Windows; siehe die datierte [Support-Matrix](SUPPORT_MATRIX.json). +macOS/Linux benötigen außerdem `curl`, `tar` und ein SHA-256-Werkzeug; zum Einrichten von Bun ist `unzip` nötig; Updates nutzen `rsync`, falls installiert, sonst einen eingebauten Abgleich mit `tar`/`find`. Die aktuelle Prüfung mit realen Clients konzentriert sich auf Windows; siehe die datierte [Support-Matrix](SUPPORT_MATRIX.json). @@ -175,6 +175,10 @@ zcode-kit auth status - **Befehl nicht gefunden:** Terminal neu öffnen. Bei Release-Installationen prüfen, ob `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) oder `$HOME/.local/bin` (macOS/Linux) im PATH steht. - **Assistent beim Setup übersprungen:** Beim nächsten Mal `y` antworten, `zcode-kit integrate ` ausführen (`zcode-kit setup --harness ` für mehrere) oder mit `zcode-kit setup --reask` alle erkannten Assistenten erneut abfragen lassen. Ohne Terminal Assistenten mit `ZCODE_KIT_HARNESSES` auswählen. - **Manuelle Client-Einrichtung:** `zcode-kit proxy status` gibt Basis-URLs, Schlüssel und Modell-IDs aus, solange der Proxy läuft; den vollständigen Schlüssel nur in einem interaktiven Terminal (sonst `zcode-kit models --show-key`). +- **Mehrere Assistenten auf einmal wählen:** `zcode-kit setup --select` zeigt eine nummerierte Liste der erkannten Assistenten statt einer Frage pro Assistent (`1,3`, `all` oder `none`); die gewählten werden eingerichtet, die übrigen erkannten als übersprungen gespeichert. +- **Gespeicherte Antwort vergessen:** `zcode-kit doctor` meldet unlesbare oder verwaiste Entscheidungsdateien; `zcode-kit doctor --forget ` entfernt eine davon (die Integration bleibt, das nächste Setup fragt erneut; rückgängig mit `zcode-kit rollback`). +- **Skripte und Monitoring:** `zcode-kit proxy status --json` gibt ein JSON-Objekt mit Zustand, Basis-URLs, Modell-IDs und Kontingent aus; der Schlüssel ist nie enthalten. +- **Gateway beim Anbieter umgezogen?** `zcode-kit doctor --upstream` vergleicht das Gateway, das der Proxy des Kits nutzt, mit der Provider-Konfiguration, die der ZCode-Client aktuell erhält (zwei Anfragen an zcode.z.ai, ohne Zugangsdaten; nur auf ausdrücklichen Wunsch). - **Keine Modellantwort:** Desktop-Login und verfügbares Kontingent prüfen. Proxy starten, wenn dein Client ihn nicht startet. Eine lokale Gesundheitsprüfung beweist keinen Modellzugriff. - **Proxy aus oder antwortet nicht:** `zcode-kit doctor --fix` oder `zcode-kit proxy restart` ausführen. Beendet wird nur ein nachweislich eigener, hängender Proxy; siehe den Abschnitt zum manuellen Proxy-Betrieb oben. - **OMP-Autostart:** OMP direkt starten. Das Setup fixiert natives Node/Bun; die Erweiterung führt die Vorprüfung in einem neuen Kindprozess aus, statt Kit-Module in OMP zu importieren. Fehler melden Kategorien ohne Geheimnisse; der Kindprozess ist auf 120 Sekunden begrenzt. Ursache beheben und nach der sitzungsbezogenen Wartezeit von 60 Sekunden erneut versuchen; eine Wiederherstellung ist in derselben Sitzung möglich. Wurde die Laufzeit verschoben, `zcode-kit setup --harness auto` erneut ausführen und die Erweiterung neu laden. Unbekannte Portbesitzer bleiben unberührt. diff --git a/README.es.md b/README.es.md index c1e823d..b614d2d 100644 --- a/README.es.md +++ b/README.es.md @@ -47,7 +47,7 @@ Puedes ejecutarlo desde cualquier directorio; no necesitas clonar el repositorio Ubicaciones predeterminadas: `%LOCALAPPDATA%\zcode-agent-kit` en Windows; `$HOME/.local/share/zcode-agent-kit` en macOS/Linux. Mantén esta carpeta separada de tus proyectos. No uses tu carpeta personal ni una copia del código fuente como destino: las actualizaciones sustituyen los archivos del directorio elegido. -macOS/Linux también necesitan `curl`, `tar` y una utilidad SHA-256; para instalar Bun hace falta `unzip` y para actualizar, `rsync`. Las pruebas actuales con clientes reales se centran en Windows; consulta la [matriz de compatibilidad](SUPPORT_MATRIX.json), que incluye fechas. +macOS/Linux también necesitan `curl`, `tar` y una utilidad SHA-256; para instalar Bun hace falta `unzip`; las actualizaciones usan `rsync` si está instalado y, si no, una réplica integrada con `tar`/`find`. Las pruebas actuales con clientes reales se centran en Windows; consulta la [matriz de compatibilidad](SUPPORT_MATRIX.json), que incluye fechas. @@ -175,6 +175,10 @@ zcode-kit auth status - **No se encuentra el comando:** vuelve a abrir la terminal. En instalaciones de una versión publicada, comprueba que `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) o `$HOME/.local/bin` (macOS/Linux) esté en PATH. - **Asistente omitido durante la configuración:** responde `y` la próxima vez, ejecuta `zcode-kit integrate ` (`zcode-kit setup --harness ` para varios) o `zcode-kit setup --reask` para que vuelva a preguntar por cada asistente detectado. Sin terminal, selecciona los asistentes con `ZCODE_KIT_HARNESSES`. - **Configuración manual del cliente:** `zcode-kit proxy status` imprime las URL base, la clave y los ID de modelo mientras el proxy está en ejecución; la clave completa solo en una terminal interactiva (`zcode-kit models --show-key` en otro caso). +- **Elegir varios asistentes a la vez:** `zcode-kit setup --select` muestra una lista numerada de los asistentes detectados en lugar de una pregunta por asistente (`1,3`, `all` o `none`); los elegidos se configuran y los demás detectados se guardan como omitidos. +- **Olvidar una respuesta guardada:** `zcode-kit doctor` informa de archivos de decisión ilegibles o huérfanos; `zcode-kit doctor --forget ` elimina uno (la integración se mantiene y la siguiente configuración vuelve a preguntar; se deshace con `zcode-kit rollback`). +- **Scripts y monitorización:** `zcode-kit proxy status --json` imprime un objeto JSON con el estado, las URL base, los ID de modelo y la cuota; nunca contiene la clave. +- **¿El proveedor cambió un gateway?** `zcode-kit doctor --upstream` compara el gateway que usa el proxy del kit con la configuración de proveedores que recibe actualmente el cliente de ZCode (dos solicitudes a zcode.z.ai, sin credenciales; solo si lo pides). - **No responde el modelo:** comprueba la sesión de Desktop y la cuota disponible. Inicia el proxy si tu cliente no lo hace. Una comprobación de salud local no demuestra acceso al modelo. - **Proxy detenido o sin respuesta:** ejecuta `zcode-kit doctor --fix` o `zcode-kit proxy restart`. Solo se termina un proxy bloqueado cuya propiedad por el kit esté demostrada; consulta la sección de gestión manual del proxy más arriba. - **Autoarranque de OMP:** ejecuta OMP directamente. La configuración fija Node/Bun nativos; la extensión realiza la comprobación previa en un proceso hijo nuevo, sin importar módulos del kit en OMP. Los fallos muestran categorías sin secretos; el proceso hijo tiene un límite de 120 segundos. Corrige la causa indicada y reintenta tras los 60 segundos de espera por sesión; es posible recuperarse en la misma sesión. Si cambió la ubicación del runtime, ejecuta de nuevo `zcode-kit setup --harness auto` y recarga la extensión. No se modifican procesos desconocidos que ocupen el puerto. diff --git a/README.ja.md b/README.ja.md index fc77674..05101b3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,7 +47,7 @@ curl -fsSL https://github.com/ZepiGit/ZCode-Agent-Kit/releases/latest/download/i 既定の場所は、Windows では `%LOCALAPPDATA%\zcode-agent-kit`、macOS/Linux では `$HOME/.local/share/zcode-agent-kit` です。作業中のプロジェクトとは別のフォルダーにしてください。ホームフォルダーやソースのチェックアウトをインストール先に指定しないでください。更新時にインストール先のファイルが置き換えられます。 -macOS/Linux では `curl`、`tar`、SHA-256 ユーティリティも必要です。Bun の導入には `unzip`、更新には `rsync` が必要です。現在、実際のクライアントを使った検証は Windows が中心です。日付付きの [サポートマトリクス](SUPPORT_MATRIX.json) を参照してください。 +macOS/Linux では `curl`、`tar`、SHA-256 ユーティリティも必要です。Bun の導入には `unzip` が必要です。更新では `rsync` があればそれを使い、なければ内蔵の `tar`/`find` によるミラーを使います。現在、実際のクライアントを使った検証は Windows が中心です。日付付きの [サポートマトリクス](SUPPORT_MATRIX.json) を参照してください。 @@ -175,6 +175,10 @@ zcode-kit auth status - **コマンドが見つからない:** ターミナルを開き直してください。リリース版のインストールでは、`%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) または `$HOME/.local/bin` (macOS/Linux) が PATH にあるか確認します。 - **セットアップでアシスタントをスキップした:** 次回 `y` と答えるか、`zcode-kit integrate <アシスタント>`(複数なら `zcode-kit setup --harness <リスト>`)を実行するか、`zcode-kit setup --reask` で検出したすべてのアシスタントについて改めて質問させます。ターミナルがない場合は `ZCODE_KIT_HARNESSES` でアシスタントを選択します。 - **クライアントの手動設定:** `zcode-kit proxy status` はプロキシの実行中にベース URL、キー、モデル ID を表示します。完全なキーは対話的なターミナルでのみ表示されます(それ以外は `zcode-kit models --show-key`)。 +- **複数のアシスタントをまとめて選ぶ:** `zcode-kit setup --select` は、アシスタントごとの質問の代わりに、検出されたアシスタントの番号付きリストを 1 つ表示します(`1,3`、`all`、`none`)。選んだものは設定され、選ばなかった検出済みのものはスキップとして記録されます。 +- **保存した回答を忘れる:** `zcode-kit doctor` は読めない決定ファイルや対象のない決定ファイルを報告します。`zcode-kit doctor --forget <アシスタント>` でそれを削除できます(統合はそのまま残り、次のセットアップで再び質問されます。`zcode-kit rollback` で元に戻せます)。 +- **スクリプトと監視:** `zcode-kit proxy status --json` は、状態、ベース URL、モデル ID、クォータを含む JSON オブジェクトを 1 つ出力します。キーは含まれません。 +- **ベンダーがゲートウェイを移した?** `zcode-kit doctor --upstream` は、kit のプロキシが使うゲートウェイを、ZCode クライアントが現在受け取るプロバイダー設定と比較します(zcode.z.ai への 2 回のリクエスト、認証情報なし。明示的に指定した場合のみ)。 - **モデルから返答がない:** Desktop のログインと残りの利用枠を確認します。クライアントがプロキシを起動しない場合は、手動で起動します。ローカルのヘルスチェックだけではモデルへのアクセスは確認できません。 - **プロキシが停止している、または応答しない:** `zcode-kit doctor --fix` または `zcode-kit proxy restart` を実行します。終了されるのは、kit 自身のものと証明された応答のないプロキシだけです。上の手動操作のセクションを参照してください。 - **OMP の自動起動:** OMP を直接起動します。セットアップでネイティブの Node/Bun を固定し、拡張機能は kit モジュールを OMP にインポートせず、新しい子プロセスで事前確認を行います。失敗時は機密情報を含まないカテゴリを表示し、子プロセスの実行時間は最大 120 秒です。原因を修正し、セッションごとの 60 秒の待機時間後に再試行してください。同じセッション内で復旧できます。ランタイムを移動した場合は `zcode-kit setup --harness auto` を再実行し、拡張機能を再読み込みします。ポートを使用している不明なプロセスには干渉しません。 diff --git a/README.md b/README.md index cc08b59..e6d7616 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Run from any directory. No clone is needed. The installer runs setup and can ins Defaults: `%LOCALAPPDATA%\zcode-agent-kit` on Windows; `$HOME/.local/share/zcode-agent-kit` on macOS/Linux. Keep this folder separate from your working projects. Never point an installer at your home folder or source checkout: updates replace files at the destination. -macOS/Linux also need `curl`, `tar`, and a SHA-256 utility; Bun bootstrap needs `unzip`, and updates need `rsync`. Current live-client validation is Windows-focused; see the dated [support matrix](SUPPORT_MATRIX.json). +macOS/Linux also need `curl`, `tar`, and a SHA-256 utility; Bun bootstrap needs `unzip`; updates use `rsync` when it is installed and a built-in `tar`/`find` mirror otherwise. Current live-client validation is Windows-focused; see the dated [support matrix](SUPPORT_MATRIX.json). @@ -175,6 +175,10 @@ zcode-kit auth status - **Command not found:** reopen the terminal. For release installs, check that `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) or `$HOME/.local/bin` (macOS/Linux) is on PATH. - **Assistant skipped during setup:** answer `y` next time, run `zcode-kit integrate ` (`zcode-kit setup --harness ` for several), or `zcode-kit setup --reask` to be asked again for every detected assistant. Without a terminal, select assistants with `ZCODE_KIT_HARNESSES`. - **Manual client setup:** `zcode-kit proxy status` prints the base URLs, key and model IDs while the proxy runs; the full key only in an interactive terminal (`zcode-kit models --show-key` otherwise). +- **Choose several assistants at once:** `zcode-kit setup --select` shows one numbered list of the detected assistants instead of one question each (`1,3`, `all` or `none`); the chosen ones are configured, the other detected ones are recorded as skipped. +- **Forget a stored answer:** `zcode-kit doctor` reports unreadable or orphaned decision files; `zcode-kit doctor --forget ` removes one (the integration stays, the next setup asks again; undo with `zcode-kit rollback`). +- **Scripts and monitoring:** `zcode-kit proxy status --json` prints one JSON object with health, base URLs, model IDs and quota; it never contains the key. +- **Vendor moved a gateway?** `zcode-kit doctor --upstream` compares the gateway the kit's proxy uses with the provider configuration the ZCode client currently receives (two requests to zcode.z.ai, no credentials; only when you ask for it). - **No model reply:** check the Desktop login and available quota. Start the proxy if your client does not start it. Local health does not prove model access. - **Proxy down or not answering:** run `zcode-kit doctor --fix` or `zcode-kit proxy restart`. Only a proven-own hung proxy is terminated; see the manual proxy section above. - **OMP autostart:** launch OMP directly. Setup pins native Node/Bun; the extension runs preflight in a fresh child instead of importing kit modules into OMP. Failures report secret-free categories; the child is limited to 120 seconds. Fix the reported cause, then retry after the 60-second per-session cooldown; recovery is possible in the same session. If the runtime moved, rerun `zcode-kit setup --harness auto` and reload the extension. Unknown port owners are left untouched. diff --git a/README.zh-CN.md b/README.zh-CN.md index abeaa1c..31bd8e4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,7 +47,7 @@ curl -fsSL https://github.com/ZepiGit/ZCode-Agent-Kit/releases/latest/download/i 默认位置:Windows 下为 `%LOCALAPPDATA%\zcode-agent-kit`;macOS/Linux 下为 `$HOME/.local/share/zcode-agent-kit`。请将此目录与工作项目分开。不要将安装目标设为主目录或源码检出目录:更新会替换目标目录里的文件。 -macOS/Linux 还需要 `curl`、`tar` 和 SHA-256 工具;安装 Bun 需要 `unzip`,更新需要 `rsync`。目前实际客户端验证主要针对 Windows;请参阅带日期的[支持矩阵](SUPPORT_MATRIX.json)。 +macOS/Linux 还需要 `curl`、`tar` 和 SHA-256 工具;安装 Bun 需要 `unzip`;更新时若已安装 `rsync` 则使用它,否则使用内置的 `tar`/`find` 同步。目前实际客户端验证主要针对 Windows;请参阅带日期的[支持矩阵](SUPPORT_MATRIX.json)。 @@ -175,6 +175,10 @@ zcode-kit auth status - **找不到命令:**重新打开终端。对于正式版安装,请检查 `%LOCALAPPDATA%\Microsoft\WindowsApps`(Windows)或 `$HOME/.local/bin`(macOS/Linux)是否位于 PATH 中。 - **设置时跳过了助手:**下次回答 `y`,运行 `zcode-kit integrate <助手>`(多个助手用 `zcode-kit setup --harness <列表>`),或运行 `zcode-kit setup --reask` 对每个检测到的助手重新提问。没有终端时,用 `ZCODE_KIT_HARNESSES` 选择助手。 - **手动配置客户端:**代理运行期间,`zcode-kit proxy status` 会输出基础 URL、密钥和模型 ID;完整密钥只在交互式终端显示(否则使用 `zcode-kit models --show-key`)。 +- **一次选择多个助手:**`zcode-kit setup --select` 会显示检测到的助手的编号列表,而不是逐个提问(输入 `1,3`、`all` 或 `none`);选中的会被配置,其余检测到的记录为已跳过。 +- **忘记已保存的回答:**`zcode-kit doctor` 会报告无法读取或没有对应助手的决定文件;`zcode-kit doctor --forget <助手>` 可删除其中一个(集成保持不变,下次设置会再次询问;可用 `zcode-kit rollback` 撤销)。 +- **脚本与监控:**`zcode-kit proxy status --json` 输出一个包含状态、基础 URL、模型 ID 和配额的 JSON 对象;其中从不包含密钥。 +- **服务商迁移了网关?**`zcode-kit doctor --upstream` 会将 Kit 代理使用的网关与 ZCode 客户端当前收到的服务商配置进行比较(向 zcode.z.ai 发送两个请求,不带凭据;仅在显式指定时运行)。 - **模型没有回复:**检查 Desktop 登录状态和可用额度。如果客户端不会自动启动代理,请手动启动。本地健康检查不能证明模型可访问。 - **代理未运行或无响应:**运行 `zcode-kit doctor --fix` 或 `zcode-kit proxy restart`。只会终止经证明属于本 Kit 的挂起代理;参阅上方手动管理代理部分。 - **OMP 自启动:**直接启动 OMP。设置过程固定原生 Node/Bun;扩展在新的子进程中执行预检查,而不是将 Kit 模块导入 OMP。失败时会报告不含秘密信息的错误类别;子进程最长运行 120 秒。修复所报告的原因后,等待该会话的 60 秒冷却时间再重试;可在同一会话中恢复。若运行时位置已变更,请重新运行 `zcode-kit setup --harness auto` 并重新加载扩展。不会干预占用端口的未知进程。 diff --git a/cli/connection-details.mjs b/cli/connection-details.mjs index 3c5ca89..3a6de95 100644 --- a/cli/connection-details.mjs +++ b/cli/connection-details.mjs @@ -50,6 +50,33 @@ export function isLoopbackHost(host) { return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host); } +function baseUrlFor(host, port) { + const urlHost = isIP(host) === 6 ? `[${host}]` : isLoopbackHost(host) ? "127.0.0.1" : host; + return `http://${urlHost}:${port}`; +} + +/** + * Machine-readable connection details (`proxy status --json`). The key is + * never included, whatever the terminal: JSON is for programs and logs, and + * `zcode-kit models --show-key` stays the explicit export. + */ +export function connectionDetailsObject({ port, models = DEFAULT_MODEL_IDS, modelsSource = "config", source = "configured", host = "127.0.0.1", responsesEnabled = true }) { + const base = baseUrlFor(host, port); + return { + source: source === "running" ? "running" : "configured", + openaiBaseUrl: `${base}/v1`, + anthropicBaseUrl: base, + routes: { + openai: responsesEnabled ? ["POST /chat/completions", "POST /responses", "GET /models"] : ["POST /chat/completions", "GET /models"], + anthropic: ["POST /v1/messages"], + }, + models: [...models], + modelsSource: modelsSource === "live" ? "live" : "config", + key: { redacted: true, export: "zcode-kit models --show-key" }, + loopback: isLoopbackHost(host), + }; +} + /** * Plain-text lines (no colour, no emojis). `source` is "running" only when * the caller verified the proxy answered as this installation's own. @@ -57,8 +84,7 @@ export function isLoopbackHost(host) { export function connectionDetailsLines({ port, key, models = DEFAULT_MODEL_IDS, modelsSource = "config", source = "configured", reveal = false, host = "127.0.0.1", responsesEnabled = true, indent = " " }) { const loopback = isLoopbackHost(host); // An IPv6 literal needs brackets in a URL; the loopback one is [::1], not 127.0.0.1. - const urlHost = isIP(host) === 6 ? `[${host}]` : loopback ? "127.0.0.1" : host; - const base = `http://${urlHost}:${port}`; + const base = baseUrlFor(host, port); const keyText = reveal && typeof key === "string" && key.length ? key : REDACTED_KEY; const verified = source === "running"; const openaiRoutes = responsesEnabled ? "POST /chat/completions, POST /responses, GET /models" : "POST /chat/completions, GET /models"; diff --git a/cli/harness-consent.mjs b/cli/harness-consent.mjs index c77a52d..5967b8f 100644 --- a/cli/harness-consent.mjs +++ b/cli/harness-consent.mjs @@ -11,7 +11,7 @@ // consented to. A kit-owned integration that predates the questions (proven // by the adapter's `owned(ctx)`) is refreshed on unattended runs but never // turned into consent: the next interactive run still asks. -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { createInterface } from "node:readline"; import { commitFile, ensureDir } from "../lib/edit.mjs"; @@ -111,6 +111,22 @@ export function createPrompter({ input = process.stdin, output = process.stdout, output.write("Please answer y or n.\n"); } }, + /** + * One free-form line, validated by `parse` (returns undefined to ask + * again, printing `retryHint`). Undefined without a terminal or on closed + * input; Ctrl-C rejects with ABORTED like `ask`. + */ + async askLine(promptText, parse, retryHint) { + if (!interactive) return undefined; + ensure(); + for (;;) { + const line = await nextLine(promptText); + if (line === undefined) return undefined; + const value = parse(line.trim()); + if (value !== undefined) return value; + output.write(`${retryHint}\n`); + } + }, close() { closed = true; if (rl) { const current = rl; rl = null; current.close(); } @@ -119,6 +135,27 @@ export function createPrompter({ input = process.stdin, output = process.stdout, }; } +/** + * Parse the `setup --select` answer for a numbered list of `count` entries: + * "all", "none", or 1-based numbers separated by commas/spaces. Returns the + * set of 0-based indexes, or undefined when the answer is invalid. + */ +export function parseSelection(answer, count) { + const text = answer.toLowerCase(); + if (text === "all") return new Set([...Array(count).keys()]); + if (text === "none") return new Set(); + const parts = text.split(/[\s,]+/).filter(Boolean); + if (!parts.length) return undefined; + const chosen = new Set(); + for (const part of parts) { + if (!/^\d+$/.test(part)) return undefined; + const n = Number(part); + if (n < 1 || n > count) return undefined; + chosen.add(n - 1); + } + return chosen; +} + /** Single y/n question on its own terminal session (see createPrompter). */ export async function askYesNo(question, streams = {}) { const prompter = createPrompter(streams); @@ -295,9 +332,62 @@ export function consentedForRepair(ctx, ids, detected, adapters, choices = readH } /** Doctor view of a harness that is neither explicitly requested nor consented. */ +/** Ids that have a decision file on disk (readable or not), including unknown ids. */ +export function storedDecisionIds(ctx) { + const dir = harnessChoicesDir(ctx); + try { + return readdirSync(dir).filter((name) => /^[a-z0-9-]+\.json$/.test(name)).map((name) => name.slice(0, -".json".length)).sort(); + } catch { + return []; + } +} + +/** + * Doctor checks for the decision files themselves (ok: true PASS, false FAIL, + * null SKIP): an unreadable file blocks every refresh of its harness, so it + * is a failure with the way out; a decision for a harness that is not + * installed (any more) or for an unknown id is kept and only noted. + */ +export function decisionFileChecks(ctx, knownIds, detected, choices = readHarnessChoices(ctx)) { + const checks = []; + if (choices.unreadable.includes("*")) { + checks.push({ name: "harness decisions", ok: false, detail: `${harnessChoicesDir(ctx)} unreadable — check its permissions; every harness counts as undecided` }); + return checks; + } + const ids = storedDecisionIds(ctx); + for (const id of ids) { + const known = knownIds.includes(id); + if (choices.unreadable.includes(id)) { + checks.push({ name: `${id}: decision file`, ok: false, detail: `unreadable — the harness counts as undecided and is never refreshed; fix it or remove it: zcode-kit doctor --forget ${id}` }); + } else if (!known) { + checks.push({ name: `${id}: decision file`, ok: null, detail: `unknown harness id — ignored; remove it with: zcode-kit doctor --forget ${id}` }); + } else if (!detected[id]) { + checks.push({ name: `${id}: decision file`, ok: null, detail: `kept (${choices.harnesses[id].decision}), but ${id} is not detected — used again when it is installed; remove with: zcode-kit doctor --forget ${id}` }); + } + } + if (ids.length && !checks.some((c) => c.ok === false)) { + checks.push({ name: "harness decisions", ok: true, detail: `${ids.length} stored decision(s) readable` }); + } + return checks; +} + +/** + * Remove one stored decision through the transaction (a rollback restores + * it). The integration itself is never touched: the next setup asks again. + * Works on unreadable files too. Returns { removed, file }. + */ +export function forgetHarnessChoice(ctx, tx, id) { + if (!/^[a-z0-9-]+$/.test(id)) throw new Error(`invalid harness id "${id}"`); + const file = join(harnessChoicesDir(ctx), `${id}.json`); + if (!existsSync(file)) return { removed: false, file }; + tx.touch(file); + unlinkSync(file); + return { removed: true, file }; +} + export function consentStatus(ctx, id, adapter, detected, choices = readHarnessChoices(ctx)) { if (!detected) return { verify: false, detail: "not detected — skipped" }; - if (isUnreadableChoice(choices, id)) return { verify: false, detail: "decision file unreadable — fix or remove it (see zcode-kit setup)" }; + if (isUnreadableChoice(choices, id)) return { verify: false, detail: `decision file unreadable — fix it or remove it: zcode-kit doctor --forget ${id}` }; const stored = choices.harnesses[id]?.decision; if (stored === "configured") return { verify: true }; if (stored === "skipped") return { verify: false, detail: "not configured (your choice) — zcode-kit integrate " + id + " to change" }; diff --git a/cli/upstream-config.mjs b/cli/upstream-config.mjs new file mode 100644 index 0000000..c0923c3 --- /dev/null +++ b/cli/upstream-config.mjs @@ -0,0 +1,179 @@ +// `zcode-kit doctor --upstream`: compare the gateway the kit's proxy uses with +// the provider configuration the ZCode client itself receives. +// +// The ZCode client (3.14) asks GET {origin}/api/v1/client/configs for a +// remote "builtin provider" release (data.configs.builtin_provider_config_json, +// a CDN JSON with providerRules[].config.{access,api.baseUrl,builtinModelIds}) +// and overrides its bundled table with it; older app versions get a provider +// list in data.providers[] instead. When the vendor moves a gateway, the +// client follows at once while the kit keeps its compiled constants — this +// check makes that visible. Opt-in (two unauthenticated GETs to the vendor), +// never part of the default doctor; nothing secret is sent. +import { existsSync, readFileSync } from "node:fs"; +import { isIP } from "node:net"; +import { configuredModelIds } from "./connection-details.mjs"; + +export const DEFAULT_UPSTREAM_ORIGIN = "https://zcode.z.ai"; +export const UPSTREAM_ORIGIN_ENV = "ZCODE_KIT_UPSTREAM_ORIGIN"; +/** Platform value the kit's proxy already sends to the same endpoint (captcha config). */ +const PLATFORM = "win32-x64"; +const TIMEOUT_MS = 8000; +/** Client release the kit was last compared with (zai-org/zcode 3.14.3); used when the configured version gets no plan data. */ +export const REFERENCE_APP_VERSION = "3.14.3"; +const MAX_BYTES = 2 * 1024 * 1024; + +/** + * Anthropic gateway base the kit's proxy uses per plan and provider. Mirrors + * zcode-proxy-src/src/provider/providers.ts (anthropicBaseURL) and + * src/proxy/upstream.ts (STARTPLAN_ANTHROPIC_BASE + "/anthropic"); a kit test + * pins that the two stay equal. + */ +export const KIT_GATEWAY_BASES = Object.freeze({ + "coding-plan": Object.freeze({ zai: "https://api.z.ai/api/anthropic", bigmodel: "https://open.bigmodel.cn/api/anthropic" }), + "start-plan": Object.freeze({ zai: "https://zcode.z.ai/api/v1/zcode-plan/anthropic", bigmodel: "https://zcode.z.ai/api/v1/zcode-plan/anthropic" }), +}); + +/** Upstream access modes that correspond to a kit plan. */ +const MODES_FOR_PLAN = { "coding-plan": ["individual-coding-plan", "team-coding-plan"], "start-plan": ["start-plan"] }; +const LEGACY_PROVIDER_IDS = { "z-ai": "zai", bigmodel: "bigmodel" }; + +/** provider, plan, identity.appVersion and models from proxy/config.yaml (template defaults when absent). */ +export function readProxyTarget(configPath) { + let text = ""; + try { if (configPath && existsSync(configPath)) text = readFileSync(configPath, "utf8"); } catch { text = ""; } + const top = (key) => text.match(new RegExp(`^${key}:[ \\t]*["']?([A-Za-z0-9._-]+)["']?[ \\t]*(?:#.*)?$`, "m"))?.[1]; + const appVersion = text.match(/^identity:[ \t]*\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+appVersion:[ \t]*["']?([0-9A-Za-z._-]+)["']?/m)?.[1]; + return { provider: top("provider") ?? "zai", plan: top("plan") ?? "start-plan", appVersion: appVersion ?? "3.11.2", models: configuredModelIds(configPath) }; +} + +function isLoopbackUrl(url) { + const host = url.hostname.replace(/^\[|\]$/g, ""); + return host === "localhost" || host === "::1" || (isIP(host) === 4 && host.startsWith("127.")); +} + +/** The config origin: https only, except an explicit loopback test origin. */ +export function resolveUpstreamOrigin(env = process.env) { + const raw = env[UPSTREAM_ORIGIN_ENV]; + if (!raw) return DEFAULT_UPSTREAM_ORIGIN; + const url = new URL(raw); + if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackUrl(url)))) { + throw new Error(`${UPSTREAM_ORIGIN_ENV} must be an https origin (http only for loopback)`); + } + return url.origin; +} + +async function getJson(url, fetchImpl, signal) { + const res = await fetchImpl(url, { method: "GET", redirect: "error", credentials: "omit", signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + if (text.length > MAX_BYTES) throw new Error("response too large"); + return JSON.parse(text); +} + +/** + * Fetch the provider entries the ZCode client of `appVersion` receives. + * Returns { shape: "release"|"legacy", revision?, entries: [{ provider, mode, baseUrl, models }] }. + */ +export async function fetchUpstreamProviders({ appVersion, origin = resolveUpstreamOrigin(), fetchImpl = fetch, timeoutMs = TIMEOUT_MS }) { + // One budget for both requests together. + const signal = AbortSignal.timeout(timeoutMs); + const configUrl = new URL("/api/v1/client/configs", origin); + configUrl.searchParams.set("app_version", appVersion); + configUrl.searchParams.set("platform", PLATFORM); + const payload = await getJson(configUrl, fetchImpl, signal); + if (payload?.code !== 0 || !payload.data || typeof payload.data !== "object") throw new Error(`unexpected answer (code ${payload?.code ?? "?"})`); + const releaseRef = payload.data.configs?.builtin_provider_config_json; + if (typeof releaseRef === "string") { + const releaseUrl = new URL(releaseRef); + const originUrl = new URL(origin); + const trusted = releaseUrl.protocol === "https:" && !releaseUrl.username && !releaseUrl.password + && (releaseUrl.hostname === "z.ai" || releaseUrl.hostname.endsWith(".z.ai")); + const testLocal = isLoopbackUrl(originUrl) && releaseUrl.origin === originUrl.origin; + if (!trusted && !testLocal) throw new Error(`release URL on an unexpected host (${releaseUrl.hostname})`); + const release = await getJson(releaseUrl, fetchImpl, signal); + const rules = release?.config?.providerConfigRules?.providerRules; + if (!Array.isArray(rules)) throw new Error("release without providerRules"); + return { + shape: "release", + revision: Number.isInteger(release.revision) ? release.revision : null, + entries: rules.map((rule) => ({ + provider: rule?.config?.access?.accountType ?? null, + mode: rule?.config?.access?.mode ?? null, + baseUrl: rule?.config?.api?.baseUrl ?? null, + models: Array.isArray(rule?.config?.builtinModelIds) ? rule.config.builtinModelIds.map(String) : [], + })).filter((e) => typeof e.provider === "string" && typeof e.mode === "string" && typeof e.baseUrl === "string"), + }; + } + if (Array.isArray(payload.data.providers)) { + return { + shape: "legacy", + entries: payload.data.providers + .filter((p) => p?.schema === "anthropic" && LEGACY_PROVIDER_IDS[p?.id] && typeof p.baseUrl === "string") + .map((p) => ({ + provider: LEGACY_PROVIDER_IDS[p.id], + mode: "individual-coding-plan", + baseUrl: p.baseUrl, + models: Array.isArray(p.models) ? p.models.map((m) => String(m?.modelId ?? "")).filter(Boolean) : [], + })), + }; + } + throw new Error("answer carries neither a provider release nor a provider list"); +} + +const norm = (url) => String(url).replace(/\/+$/, "").toLowerCase(); + +/** Doctor checks (ok: true PASS, false FAIL, null SKIP) for the configured plan and provider. */ +export function compareUpstream(target, upstream) { + const checks = []; + const expected = KIT_GATEWAY_BASES[target.plan]?.[target.provider]; + const label = `upstream gateway (${target.provider}, ${target.plan})`; + if (!expected) { + checks.push({ name: label, ok: null, detail: "plan/provider not covered by this check" }); + return checks; + } + const modes = MODES_FOR_PLAN[target.plan] ?? []; + const matching = upstream.entries.filter((e) => e.provider === target.provider && modes.includes(e.mode)); + const source = upstream.shape === "release" ? `remote provider release${upstream.revision !== null ? ` rev ${upstream.revision}` : ""}` : "provider list"; + if (!matching.length) { + checks.push({ name: label, ok: null, detail: `not listed in the ${source} served to app ${target.appVersion} — nothing to compare` }); + return checks; + } + const bases = [...new Set(matching.map((e) => e.baseUrl))]; + if (bases.some((b) => norm(b) === norm(expected))) { + checks.push({ name: label, ok: true, detail: `${expected} matches the ${source}` }); + } else { + checks.push({ name: label, ok: false, detail: `the ZCode client now uses ${bases.join(", ")} (${source}); the kit's proxy still sends to ${expected} — update the kit (zcode-kit update) or report it` }); + } + const offered = new Set(matching.flatMap((e) => e.models.map((m) => m.toLowerCase()))); + if (offered.size) { + const missing = target.models.filter((m) => !offered.has(m.toLowerCase())); + checks.push({ + name: `upstream models (${target.plan})`, + ok: missing.length ? null : true, + detail: missing.length + ? `configured but not offered to the ZCode client for this plan: ${missing.join(", ")} (offered: ${[...offered].join(", ")}) — may still work; informational` + : `configured models offered: ${target.models.join(", ")}`, + }); + } + return checks; +} + +/** One call for doctor: never throws; a network or format problem is a SKIP. */ +export async function upstreamChecks(configPath, { fetchImpl = fetch, env = process.env, timeoutMs = TIMEOUT_MS } = {}) { + const target = readProxyTarget(configPath); + try { + const origin = resolveUpstreamOrigin(env); + const upstream = await fetchUpstreamProviders({ appVersion: target.appVersion, origin, fetchImpl, timeoutMs }); + const checks = compareUpstream(target, upstream); + // Older app versions get a provider list without the plan gateways; the + // current client's view is what the vendor actually routes, so compare + // against it as well when the configured version says nothing. + if (checks.length === 1 && checks[0].ok === null && upstream.shape === "legacy" && target.appVersion !== REFERENCE_APP_VERSION) { + const current = await fetchUpstreamProviders({ appVersion: REFERENCE_APP_VERSION, origin, fetchImpl, timeoutMs }); + return compareUpstream({ ...target, appVersion: REFERENCE_APP_VERSION }, current).map((c) => ({ ...c, detail: `${c.detail} (as served to app ${REFERENCE_APP_VERSION}; the kit announces ${target.appVersion})` })); + } + return checks; + } catch (err) { + return [{ name: "upstream gateway", ok: null, detail: `upstream config unreachable or unreadable (${String(err?.message ?? err).slice(0, 160)}) — skipped` }]; + } +} diff --git a/cli/zcode-kit.mjs b/cli/zcode-kit.mjs index a813520..9f1b69c 100644 --- a/cli/zcode-kit.mjs +++ b/cli/zcode-kit.mjs @@ -51,8 +51,10 @@ import { askAccountRotator, configureAccountRotator, restartForAccountChange, ro import { setupOutput } from "./setup-output.mjs"; import { ABORTED, HARNESS_SELECTION_ENV, NO_CONSENT_HINT, consentStatus, consentedForRepair, createPrompter, decideHarness, - isUnreadableChoice, ownedIntegration, readHarnessChoices, recordHarnessChoice, resolveHarnessSelection, + decisionFileChecks, forgetHarnessChoice, isUnreadableChoice, ownedIntegration, parseSelection, readHarnessChoices, + recordHarnessChoice, resolveHarnessSelection, storedDecisionIds, } from "./harness-consent.mjs"; +import { upstreamChecks } from "./upstream-config.mjs"; import { ACCOUNT_ROTATOR_QUESTION } from "./account-setup.mjs"; import { mkdtempSync, readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -85,7 +87,7 @@ function parseArgs(argv) { if (a.startsWith("--")) { const eq = a.indexOf("="); if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1); - else if (["fix", "json", "dry-run", "no-mcp", "yes", "help", "show-key", "installer", "verbose", "import", "paste", "replace", "live", "reask"].includes(a.slice(2))) flags[a.slice(2)] = true; + else if (["fix", "json", "dry-run", "no-mcp", "yes", "help", "show-key", "installer", "verbose", "import", "paste", "replace", "live", "reask", "select", "upstream"].includes(a.slice(2))) flags[a.slice(2)] = true; else flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; } else positional.push(a); } @@ -126,12 +128,12 @@ async function main() { function usage(code) { console.log(`zcode-kit — local ZCode provider for your own agent harnesses - zcode-kit setup [--harness auto|omp,pi,...|none] bootstrap + one y/n question per detected harness + zcode-kit setup [--harness auto|omp,pi,...|none] [--select] bootstrap + one y/n question per detected harness zcode-kit integrate [--dry-run] explicit consent for one harness zcode-kit run -- launch harness wired to ZCode - zcode-kit doctor [--fix] [--harness ] [--json] - zcode-kit status | models [--json] [--show-key] | usage --json - zcode-kit proxy start|stop|restart|status|logs [n] manage the local proxy service + zcode-kit doctor [--fix] [--harness ] [--json] [--upstream] | doctor --forget + zcode-kit status [--json] | models [--json] [--show-key] | usage --json + zcode-kit proxy start|stop|restart|status [--json]|logs [n] manage the local proxy service zcode-kit auth status|login [zai|bigmodel] [--import] [--account ID] [--replace]|logout zcode-kit accounts enable|disable zcode-kit accounts [--json|--live] | accounts remove|pause|resume ID [--yes] @@ -143,7 +145,11 @@ function usage(code) { without a terminal, undecided harnesses are skipped — never configured silently) (setup: --harness or ${HARNESS_SELECTION_ENV}= selects harnesses without questions (unattended); none skips all; the selection also limits MCP registration; --no-mcp skips registration; - answers are remembered under generated/harness-choices/ — --reask asks again) + answers are remembered under generated/harness-choices/ — --reask asks again; + --select shows one numbered list instead: chosen = configured, the other detected ones = skipped) + (doctor: --forget removes a stored decision (the integration stays; setup asks again); + --upstream compares the kit's gateway with the provider config the ZCode client receives — network, opt-in) + (proxy status --json: one object, connection details without the key) (setup: --account-rotator y|n for unattended installs; --verbose for full installer output) (proxy start/status and setup print the connection details for manual client setup; the key is shown in full only on an interactive terminal — export: zcode-kit models --show-key) @@ -250,6 +256,38 @@ async function cmdSetup() { if (!explicit && !detectedIds.length) { console.log(" No supported assistant detected. The connection details below work with any OpenAI- or Anthropic-compatible client."); } + // `--select`: one numbered list instead of one question per harness. + // Every detected harness gets a decision (chosen = configured, the rest + // = skipped); an explicit selection wins, and without a terminal the flag + // changes nothing. + let selection = null; + if (flags.select === true) { + if (explicit) { + console.log(` note: --select ignored — the explicit selection (${explicit.source === "flag" ? "--harness" : HARNESS_SELECTION_ENV}) decides.`); + } else if (!interactive) { + console.log(" note: --select needs an interactive terminal — ignored (stored decisions apply)."); + } else if (detectedIds.length) { + const labels = await Promise.all(detectedIds.map(async (id) => (await loadAdapter(id)).default.label)); + console.log("\n Detected assistants:"); + detectedIds.forEach((id, i) => { + const stored = choices.harnesses[id]?.decision; + console.log(` ${i + 1}) ${labels[i]}${stored ? ` (currently ${stored})` : ""}`); + }); + const bridgeFor = detectedIds.filter((id) => mcpBridgeAvailable && (id === "omp" || id === "claude-code")); + if (bridgeFor.length) console.log(` note: selecting ${bridgeFor.map((id) => labels[detectedIds.indexOf(id)]).join(" or ")} also registers the kit's MCP bridge "zcode-harness" there (skip with --no-mcp).`); + try { + const chosen = await prompter.askLine( + " Configure ZCode as a provider with its supported models in which assistants? (numbers, all, none) ", + (answer) => parseSelection(answer, detectedIds.length), + " Please enter numbers from the list (e.g. 1,3), all, or none.", + ); + if (chosen !== undefined) selection = new Set([...chosen].map((i) => detectedIds[i])); + } catch (err) { + if (err?.code !== ABORTED) throw err; + abortedByUser = true; + } + } + } const mcpIds = []; for (const id of ADAPTER_IDS) { const { default: adapter } = await loadAdapter(id); @@ -267,11 +305,12 @@ async function cmdSetup() { const mcpOffered = interactive && mcpBridgeAvailable && (id === "omp" || id === "claude-code"); // The question text is fixed; what a y implies beyond the provider // entry is said before it (existing integration, MCP bridge). - const ask = interactive ? (question) => { - if (owned) console.log(` note: an existing kit integration for ${adapter.label} was found; y keeps it current, n leaves it untouched.`); - if (mcpOffered) console.log(` note: y also registers the kit's MCP bridge "zcode-harness" for ${adapter.label} (skip with --no-mcp).`); - return prompter.ask(question); - } : null; + const ask = selection ? async () => selection.has(id) + : interactive ? (question) => { + if (owned) console.log(` note: an existing kit integration for ${adapter.label} was found; y keeps it current, n leaves it untouched.`); + if (mcpOffered) console.log(` note: y also registers the kit's MCP bridge "zcode-harness" for ${adapter.label} (skip with --no-mcp).`); + return prompter.ask(question); + } : null; let decision; try { decision = await decideHarness({ @@ -283,7 +322,8 @@ async function cmdSetup() { unreadable: isUnreadableChoice(choices, id), owned, explicit, - reask: flags.reask === true, + // A --select answer covers every detected harness, stored or not. + reask: flags.reask === true || selection !== null, ask, mcpOffered, mcpAllowed: !flags["no-mcp"], @@ -548,7 +588,45 @@ async function cmdRun() { } // ------------------------------------------------------------------ doctor +/** + * `doctor --forget `: remove one stored harness decision (readable or + * not) through a transaction; the integration itself stays as it is and the + * next setup asks again. + */ +async function forgetDecision() { + const id = typeof flags.forget === "string" ? flags.forget.trim() : ""; + const stored = storedDecisionIds(ctx); + if (!id || (!ADAPTER_IDS.includes(id) && !stored.includes(id))) { + console.error(`Usage: zcode-kit doctor --forget (known: ${ADAPTER_IDS.join(", ")}${stored.length ? `; stored: ${stored.join(", ")}` : ""})`); + return 2; + } + if (!stored.includes(id)) { + console.log(`No stored decision for ${id}; nothing to forget.`); + return 0; + } + assertNotCheckoutWrite(); + ensureState(ctx); + acquireLock(BACKUP_DIR); + const tx = beginTransaction(BACKUP_DIR, `zcode-kit doctor --forget ${id}`); + let txId = null; + let result; + try { + result = forgetHarnessChoice(ctx, tx, id); + } finally { + let finishErr = null; + try { txId = tx.finish(); } catch (err) { finishErr = err; } + releaseLock(join(BACKUP_DIR, ".setup-lock")); + if (finishErr) console.error(`WARN: recording the transaction failed (${finishErr.message})`); + } + if (result?.removed) { + console.log(`Decision for ${id} removed. Its integration is unchanged; the next zcode-kit setup asks again.`); + if (txId) console.log(`transaction ${txId} recorded — undo with: zcode-kit rollback ${txId}`); + } + return 0; +} + async function cmdDoctor() { + if (flags.forget !== undefined) return forgetDecision(); const ids = flags.harness ? String(flags.harness).split(",").map(s => s.trim()).filter(Boolean) : [...ADAPTER_IDS]; for (const id of ids) requireHarness(id); const detected = detectHarnesses(ctx.home); @@ -609,6 +687,16 @@ async function cmdDoctor() { } for (const c of adapter.verify(ctx)) checks.push({ name: `${id}: ${c.name}`, ok: c.ok, detail: c.detail ?? "" }); } + // The decision files themselves: an unreadable one silently blocks every + // refresh of its harness, so it is reported with the way out. + for (const c of decisionFileChecks(ctx, ADAPTER_IDS, detected, choices)) { + if (flags.harness && !ids.some((id) => c.name.startsWith(`${id}:`))) continue; + checks.push(c); + } + // Opt-in: the only doctor check that talks to the vendor (two unauthenticated GETs). + if (flags.upstream === true) { + for (const c of await upstreamChecks(join(ROOT, "proxy", "config.yaml"))) checks.push(c); + } const failed = checks.filter((c) => c.ok === false).length; const skipped = checks.filter((c) => c.ok === null).length; @@ -627,7 +715,7 @@ async function cmdDoctor() { // ------------------------------------------------------------------ status async function cmdStatus() { const manager = join(ROOT, "proxy", "zcode-proxy-manager.mjs"); - const res = spawnSync(process.execPath, [manager, "status"], { stdio: "inherit" }); + const res = spawnSync(process.execPath, [manager, "status", ...(flags.json === true ? ["--json"] : [])], { stdio: "inherit" }); return res.status ?? 1; } @@ -641,6 +729,7 @@ async function cmdProxy() { } const args = [join(ROOT, "proxy", "zcode-proxy-manager.mjs"), sub]; if (sub === "logs" && positional[2] !== undefined) args.push(String(positional[2])); + if (sub === "status" && flags.json === true) args.push("--json"); const res = spawnSync(process.execPath, args, { stdio: "inherit" }); return res.status ?? 2; } diff --git a/docs/ACCOUNT_ROTATOR.de.md b/docs/ACCOUNT_ROTATOR.de.md index c5826a2..118e9d5 100644 --- a/docs/ACCOUNT_ROTATOR.de.md +++ b/docs/ACCOUNT_ROTATOR.de.md @@ -122,3 +122,11 @@ aber nicht abgefragt; fehlende Billing-Daten bedeuten nicht null Kontingent. Kosten: Ein Aufruf stellt je eindeutigem Konto bis zu 2 Abrechnungsanfragen (Guthaben und Vorschau), 15 Sekunden zwischengespeichert. Er sendet keine Modellanfrage, löst kein Captcha und erneuert keine Tokens. + +Kann der laufende Proxy Kontodaten (Sperrzeiten, letzte Nutzung) nicht in den +Speicher schreiben — ein anderer Prozess hält die Sperre, oder ein +vorübergehender E/A-Fehler —, bleibt die Änderung erhalten und wird nach 0,25, +1, 4 und 15 Sekunden im Hintergrund erneut geschrieben; keine Anfrage wartet +darauf, und die nächste Änderung schreibt ohnehin neu. `zcode-kit accounts doctor` +zeigt `runtime_changes_unsaved`, solange eine Änderung noch nicht gespeichert +ist. Ein beschädigter Speicher wird nicht erneut versucht. diff --git a/docs/ACCOUNT_ROTATOR.md b/docs/ACCOUNT_ROTATOR.md index 6298410..5480925 100644 --- a/docs/ACCOUNT_ROTATOR.md +++ b/docs/ACCOUNT_ROTATOR.md @@ -115,3 +115,10 @@ probed; their absence from the billing response is not interpreted as zero quota Cost: one run makes up to 2 billing requests (balance and preview) per unique account, cached for 15 seconds. It sends no model request, solves no captcha, and does not refresh tokens. + +If the running proxy cannot write account metadata (cooldowns, last use) to the +store — another process holds its lock, or a transient I/O error — the change +is kept and rewritten in the background after 0.25, 1, 4 and 15 seconds; no +request waits for it, and the next change writes again anyway. +`zcode-kit accounts doctor` shows `runtime_changes_unsaved` while a change is +not on disk yet. A corrupt store is not retried. diff --git a/harnesses/README.de.md b/harnesses/README.de.md index 0c11d39..8cf3d90 100644 --- a/harnesses/README.de.md +++ b/harnesses/README.de.md @@ -106,6 +106,7 @@ Proxy-Prüfung belegt keinen Erfolg beim nativen Provider. Details: - Kontingent erschöpft → HTTP 400 `[1005] exceed quota limit`. Der Proxy wiederholt denselben Account nach einem wachsenden Zeitplan (standardmäßig bis ~65s), bevor er auf einen anderen Account wechselt, sofern der Rotator einen hat; tritt der Fehler weiter auf, warte, bis der Anbieter wieder Kontingent bereitstellt. - `[3007] captcha verify failed` → Gateway-Anti-Absicherung. Der Proxy wiederholt einmal mit einem frisch erzeugten Captcha-Token; schlägt das erneut fehl, lege eine Pause ein. - Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 sowie die Gateway-Fehlercodes, die der offizielle Client wiederholt) werden bis zu 3-mal auf demselben Konto mit wachsender Wartezeit wiederholt; das Budget ist kleiner als beim offiziellen Client (Basiswartezeit 1 s, verdoppelt; `Retry-After` bis 15 Sekunden beachtet), und das Konto wird vor jeder Wiederholung erneut geprüft. Gateway-Entscheidungen (Kontingent, Guthaben, Captcha, Modell, Authentifizierung), Anfragefehler und alles nach begonnener Ausgabe werden nie wiederholt; ein `Retry-After` über 15 Sekunden wird bei 429, 503 und 529 an den Client durchgereicht. Einstellbar über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen; proxy start/restart reicht den Wert durch). +- Ein Stream, der vor seinem ersten Inhalts-Event scheitert (ein Fehler-Event wie `overloaded_error` oder eine abgebrochene Verbindung), wird wie eine fehlgeschlagene Anfrage wiederholt; der Harness sieht nur den erfolgreichen Stream. Nach dem ersten Inhalts-Event wird nichts wiederholt. Beim Start-Plan holt jede Wiederholung nach einer Antwort ein frisches Captcha-Token. - Ein vom Upstream abgebrochener Stream endet mit einer Fehlermeldung statt einer stillen Kürzung: Chat-Streams mit `data: {"error":…}`, Responses-Streams mit `response.failed`, native Anthropic-Streams mit einem `event: error`-Frame vom Typ `api_error`, dessen Meldung die Ursache nennt (`upstream_incomplete` oder `upstream_stream_error`); ein unvollständiger letzter Frame wird verworfen, damit der Fehler-Frame lesbar bleibt. Nach begonnener Ausgabe wird nichts wiederholt; sende den Turn aus dem Harness erneut. - `401 start_plan_jwt_invalid` → Desktop-Anmeldung prüfen und mit `zcode-kit auth login zai` erneuern. Mit `zcode-kit auth login zai --import` den aktuell aktiven `zai`/`start-plan`-Login aus Desktop 0.16.9 mit ausdrücklich konfiguriertem Plan importieren. Eine vorhandene `credentials.json` ist maßgeblich; bei ungültigen Anmeldedaten erfolgt kein stiller Rückgriff auf `config.json`. Moderne `coding-plan`-Logins verwenden stattdessen normales OAuth; der Import erstellt oder ermittelt keine API-Schlüssel. - `[1210]` bei Flash → prüfen, ob Thinking aktiviert ist, und `low`, `high` oder `max` wählen, statt Thinking zu deaktivieren. Der Proxy normalisiert deaktiviertes Thinking auf `low`; siehe den Flash-Hinweis oben. diff --git a/harnesses/README.es.md b/harnesses/README.es.md index e372871..2cff75e 100644 --- a/harnesses/README.es.md +++ b/harnesses/README.es.md @@ -107,6 +107,7 @@ nativo. Detalles: [puente MCP](../mcp/zcode-harness-mcp/README.es.md). - Cuota agotada → HTTP 400 `[1005] exceed quota limit`. El proxy reintenta la misma cuenta con un calendario creciente (hasta ~65s por defecto) antes de cambiar a otra cuenta, cuando el rotador tiene una; si aún lo ves, espera a que el proveedor restablezca la cuota. - `[3007] captcha verify failed` → anti-abuso del gateway. El proxy reintenta una vez con un token CAPTCHA recién emitido; si aún falla, haz una pausa. - Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 y los códigos de error del gateway que el cliente oficial reintenta) se reintentan hasta 3 veces en la misma cuenta con una espera creciente; el presupuesto es menor que el del cliente oficial (espera base de 1 s que se duplica, `Retry-After` respetado hasta 15 segundos) y la cuenta se vuelve a comprobar antes de cada reintento. Los veredictos del gateway (cuota, saldo, captcha, modelo, autenticación), los errores de la solicitud y cualquier cosa tras el inicio de la salida nunca se reintentan; un `Retry-After` superior a 15 segundos se pasa al cliente en 429, 503 y 529. Ajústalo con `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones que nunca se establecieron; proxy start/restart lo transmite). +- Un flujo que falla antes de su primer evento de contenido (un evento de error como `overloaded_error` o una conexión cortada) se reintenta como una solicitud fallida, de modo que el harness solo ve el flujo correcto; tras el primer evento de contenido no se reintenta nada. En start-plan, cada reintento tras una respuesta usa un token CAPTCHA nuevo. - Un flujo que el upstream corta termina con un mensaje de error en lugar de un truncamiento silencioso: los flujos de chat con `data: {"error":…}`, los de Responses con `response.failed`, los flujos Anthropic nativos con un frame `event: error` de tipo `api_error` cuyo mensaje indica la causa (`upstream_incomplete` o `upstream_stream_error`); un último frame incompleto se descarta para que el frame de error siga siendo legible. Nada se repite una vez iniciada la salida; vuelve a enviar el turno desde el harness. - `401 start_plan_jwt_invalid` → comprueba la sesión de Desktop y renuévala con `zcode-kit auth login zai`. Usa `zcode-kit auth login zai --import` para el login activo de `zai`/`start-plan` en Desktop 0.16.9 con un plan configurado explícitamente. Si existe `credentials.json`, es la fuente autoritativa; unas credenciales inválidas no provocan una vuelta silenciosa a `config.json`. Los logins modernos de `coding-plan` usan el OAuth normal; la importación no crea ni obtiene claves API. - `[1210]` con Flash → comprueba que thinking esté activado y elige `low`, `high` o `max`, en lugar de desactivarlo. El proxy normaliza thinking desactivado a `low`; consulta la nota sobre Flash anterior. diff --git a/harnesses/README.ja.md b/harnesses/README.ja.md index a19940b..b73b901 100644 --- a/harnesses/README.ja.md +++ b/harnesses/README.ja.md @@ -107,6 +107,7 @@ Desktop が必要な場合がありますが、プロバイダーがモデル呼 - クォータ消費済み → HTTP 400 `[1005] exceed quota limit`。プロキシは同じアカウントを成長間隔で再試行し(既定で最大約65秒)、ローテーターに別のアカウントがあればその後に切り替えます。それでも表示される場合は、プロバイダーによる利用枠の回復を待ってください。 - `[3007] captcha verify failed` → ゲートウェイ側のアンチアビューズ。プロキシは新しく発行した CAPTCHA トークンで一度再試行します。それでも失敗する場合は、しばらく休憩してください。 - 出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、429、公式クライアントが再試行するゲートウェイのエラーコード)は、同じアカウントで待ち時間を増やしながら最大 3 回再試行します。予算は公式クライアントより小さく(基本待ち時間 1 秒で倍増、`Retry-After` は 15 秒まで尊重)、再試行のたびにアカウントを再確認します。ゲートウェイの判定(クォータ、残高、CAPTCHA、モデル、認証)、リクエストのエラー、出力開始後の障害は再試行しません。15 秒を超える `Retry-After` は 429/503/529 でクライアントにそのまま渡します。`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で調整できます(基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します。proxy start/restart が値を引き継ぎます)。 +- 最初のコンテンツイベントの前に失敗したストリーム(`overloaded_error` などのエラーイベントや切断)は、失敗したリクエストと同じように再試行されるため、ハーネスには正常なストリームだけが届きます。最初のコンテンツイベントの後は何も再試行しません。start-plan では、レスポンス後の再試行ごとに新しい CAPTCHA トークンを取得します。 - アップストリームが途中で切断したストリームは、無言で途切れる代わりにエラーで終わります。チャットのストリームは `data: {"error":…}`、Responses は `response.failed`、ネイティブの Anthropic ストリームは `api_error` 型の `event: error` フレーム 1 つで、メッセージに原因(`upstream_incomplete` または `upstream_stream_error`)が入ります。未完成の最後のフレームは、エラーフレームを正しく解析できるよう破棄します。出力開始後は何も再送しません。ハーネスからターンを送り直してください。 - `401 start_plan_jwt_invalid` → Desktop のログインを確認し、`zcode-kit auth login zai` で更新。プランが明示的に設定された Desktop 0.16.9 の現在アクティブな `zai`/`start-plan` ログインには `zcode-kit auth login zai --import` を使います。`credentials.json` が存在する場合はそれが正となり、認証情報が無効でも `config.json` へ暗黙にはフォールバックしません。新形式の `coding-plan` ログインでは通常の OAuth を使います。インポートは API キーの作成や取得を行いません。 - Flash で `[1210]` → thinking が有効か確認し、無効にする代わりに `low`、`high`、`max` を選びます。プロキシは、無効にした thinking を `low` に正規化します。上記の Flash の注記を参照してください。 diff --git a/harnesses/README.md b/harnesses/README.md index 025e793..2b40027 100644 --- a/harnesses/README.md +++ b/harnesses/README.md @@ -137,6 +137,7 @@ establish native-provider acceptance. Details: - Quota exhausted → HTTP 400 `[1005] exceed quota limit`. The proxy retries the same account on a growing schedule (up to ~65s by default) before failing over to another account, when the rotator has one; if you still see it, wait for the provider to restore quota. - `[3007] captcha verify failed` → gateway anti-abuse. The proxy retries once with a freshly minted captcha token; if it still fails, take a pause. - Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429, and the gateway error codes the official client retries) are retried up to 3 times on the same account with a growing delay; the budget is smaller than the official client's (base delay 1 s doubling, `Retry-After` honoured up to 15 seconds), and the account is re-checked before every retry. Gateway verdicts (quota, balance, captcha, model, authentication), request errors and anything after output has started are never retried; a `Retry-After` above 15 seconds is passed to the client on 429, 503 and 529. Tune with `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` (base delay in milliseconds, default 500, capped at 10000; `off` keeps only the retry of connections that were never established; proxy start/restart passes it through). +- A stream that fails before its first content event (an error event such as `overloaded_error`, or a cut connection) is retried like a failed request, so the harness sees the good stream only; after the first content event nothing is retried. On start-plan every retry after a response takes a fresh captcha token. - A stream that the upstream cuts off ends with an error payload instead of a silent truncation: chat streams get `data: {"error":…}`, Responses streams `response.failed`, native Anthropic streams one `event: error` frame of type `api_error` whose message names the cause (`upstream_incomplete` or `upstream_stream_error`); an unfinished last frame is dropped so that the error frame stays parseable. Nothing is replayed after output has started; send the turn again from the harness. - `401 start_plan_jwt_invalid` → check your Desktop login and renew it with `zcode-kit auth login zai`. Use `zcode-kit auth login zai --import` for the current active Desktop 0.16.9 `zai`/`start-plan` login with an explicitly configured plan. Existing `credentials.json` is authoritative; invalid credentials do not silently fall back to `config.json`. Modern `coding-plan` logins use normal OAuth instead; import does not create or resolve API keys. - `[1210]` with Flash → check that thinking is enabled and choose `low`, `high`, or `max`, rather than disabling it. The proxy normalizes disabled thinking to `low`; see the Flash note above. diff --git a/harnesses/README.zh-CN.md b/harnesses/README.zh-CN.md index e4ab59a..34bd13d 100644 --- a/harnesses/README.zh-CN.md +++ b/harnesses/README.zh-CN.md @@ -103,6 +103,7 @@ GLM-5.3-Flash 已通过代理路径验证。原生目录中存在模型条目, - 配额耗尽 → HTTP 400 `[1005] exceed quota limit`。代理会按递增间隔重试同一账号(默认最长约65秒),然后在轮换器有其他账号时切换账号;如果仍然出现,请等待服务商恢复额度。 - `[3007] captcha verify failed` → 网关反滥用机制。代理会使用新获取的 CAPTCHA 令牌自动重试一次;如果仍然失败,请暂停片刻。 - 输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、429,以及官方客户端会重试的网关错误码)会在同一账号上以递增等待最多重试 3 次;预算比官方客户端小(基础等待 1 秒并翻倍,`Retry-After` 最多遵守 15 秒),每次重试前都会重新检查账号。网关裁定(配额、余额、验证码、模型、认证)、请求错误以及输出开始后的任何故障都不会重试;超过 15 秒的 `Retry-After` 会在 429、503 和 529 上直接交给客户端。可用 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 调整(基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试;proxy start/restart 会传递该值)。 +- 在第一个内容事件之前失败的流(如 `overloaded_error` 这类错误事件或连接被切断)会像失败的请求一样重试,因此 harness 只会看到成功的流;第一个内容事件之后不会重试任何内容。在 start-plan 下,响应之后的每次重试都会使用新的验证码令牌。 - 被上游中断的流会以错误结束,而不是无声截断:聊天流为 `data: {"error":…}`,Responses 流为 `response.failed`,原生 Anthropic 流为一个类型为 `api_error` 的 `event: error` 帧,其消息说明原因(`upstream_incomplete` 或 `upstream_stream_error`);未完成的最后一帧会被丢弃,以保证错误帧可解析。输出开始后不会重放任何内容;请从 harness 重新发送该轮。 - `401 start_plan_jwt_invalid` → 检查 Desktop 登录,并通过 `zcode-kit auth login zai` 更新。对于 Desktop 0.16.9 当前激活且已明确配置计划的 `zai`/`start-plan` 登录,使用 `zcode-kit auth login zai --import`。只要存在 `credentials.json`,就以它为准;凭据无效时不会静默回退到 `config.json`。新版 `coding-plan` 登录使用常规 OAuth;导入不会创建或获取 API 密钥。 - Flash 返回 `[1210]` → 检查是否已启用 thinking,并选择 `low`、`high` 或 `max`,而不是禁用它。代理会将禁用的 thinking 规范化为 `low`;参见上方 Flash 说明。 diff --git a/install.sh b/install.sh index f7c54e9..7d92c64 100644 --- a/install.sh +++ b/install.sh @@ -55,9 +55,42 @@ else fi printf '%s\n' "$VERSION" | grep -Eq "$VERSION_PATTERN" || die "invalid release version; expected vX.Y.Z or vX.Y.Z-prerelease" 2 -if [ -f "$INSTALL_DIR/cli/zcode-kit.mjs" ]; then - command -v rsync >/dev/null 2>&1 || die "rsync required for updates" 2 -fi + +# Mirror a new release over an existing installation while never touching +# machine-local state: the local proxy key, the user's proxy/config.yaml, the +# Bun path, and the node_modules/backups/logs/generated directories (at any +# depth, like rsync --exclude). rsync when available; otherwise a portable +# tar pipe copies first, then entries the release no longer ships are removed. +# ZCODE_KIT_MIRROR=portable forces the portable path (diagnostics, tests). +mirror_portable() { + src=$1; dst=$2 + (cd "$src" && tar -cf - --exclude='.bun-path' --exclude='.proxykey' --exclude='config.yaml' --exclude='node_modules' \ + --exclude='backups' --exclude='logs' --exclude='generated' .) | (cd "$dst" && tar -xf -) || return 1 + # Delete only after the copy succeeded. Protected names are pruned, so + # neither they nor anything below them is ever listed for removal. Like + # rsync, a directory the release dropped goes only when it is empty after + # its own files went: one that still holds protected state stays. + (cd "$dst" && find . -mindepth 1 \( -name '.bun-path' -o -name '.proxykey' -o -name 'config.yaml' -o -name 'node_modules' \ + -o -name 'backups' -o -name 'logs' -o -name 'generated' \) -prune -o -print) > "$TMP/mirror-dst.list" || return 1 + while IFS= read -r rel; do + [ -e "$src/$rel" ] || [ -L "$src/$rel" ] && continue + if [ -d "$dst/$rel" ] && [ ! -L "$dst/$rel" ]; then continue; fi + rm -f "${dst:?}/$rel" || return 1 + done < "$TMP/mirror-dst.list" + # Directories deepest first; rmdir refuses a non-empty one, which is the point. + awk -F/ '{ print NF "\t" $0 }' "$TMP/mirror-dst.list" | sort -rn | cut -f2- | while IFS= read -r rel; do + if [ -d "$dst/$rel" ] && [ ! -L "$dst/$rel" ] && [ ! -e "$src/$rel" ]; then rmdir "$dst/$rel" 2>/dev/null || :; fi + done +} +mirror_release() { + if [ "${ZCODE_KIT_MIRROR:-}" != "portable" ] && command -v rsync >/dev/null 2>&1; then + # Excluded files are protected from --delete too (--delete-excluded is NOT set). + rsync -a --delete --exclude '.bun-path' --exclude '.proxykey' --exclude 'config.yaml' --exclude 'node_modules' \ + --exclude 'backups' --exclude 'logs' --exclude 'generated' "$1/" "$2/" + else + mirror_portable "$1" "$2" + fi +} printf '\n %sZCODE / AGENT KIT%s\n' "$C_BOLD" "$C_RESET" printf ' %s\n' "$VERSION | Your local AI workspace" printf ' --------------------------------------------\n' @@ -144,14 +177,8 @@ fi step "[3/4] Installing files" mkdir -p "$INSTALL_DIR" if [ -f "$INSTALL_DIR/cli/zcode-kit.mjs" ]; then - command -v rsync >/dev/null 2>&1 || { echo "ERROR: rsync required to update an existing installation"; exit 2; } printf ' Updating existing installation; keeping your configuration.\n' - # Excluded files are protected from --delete too (--delete-excluded is NOT - # set): the local proxy key and user proxy/config.yaml survive updates. - if ! rsync -a --delete --exclude '.bun-path' --exclude '.proxykey' --exclude 'config.yaml' --exclude 'node_modules' \ - --exclude 'backups' --exclude 'logs' --exclude 'generated' "$SRC/" "$INSTALL_DIR/"; then - die "update copy failed" 1 - fi + mirror_release "$SRC" "$INSTALL_DIR" || die "update copy failed" 1 else cp -R "$SRC/." "$INSTALL_DIR/" fi diff --git a/proxy/zcode-proxy-manager.mjs b/proxy/zcode-proxy-manager.mjs index 540e1e3..93b6e5a 100644 --- a/proxy/zcode-proxy-manager.mjs +++ b/proxy/zcode-proxy-manager.mjs @@ -36,7 +36,7 @@ import { createCtx } from "../cli/context.mjs"; import { logHeal } from "../cli/heal.mjs"; import { processCommandLine, resolveBun } from "../lib/process.mjs"; import { proxyEnv } from "../lib/proxy-env.mjs"; -import { configuredModelIds, configuredServer, connectionDetailsLines, shouldRevealKey } from "../cli/connection-details.mjs"; +import { configuredModelIds, configuredServer, connectionDetailsLines, connectionDetailsObject, shouldRevealKey } from "../cli/connection-details.mjs"; import { readHarnessChoices } from "../cli/harness-consent.mjs"; // ------------------------------------------------------------------ factory @@ -839,7 +839,8 @@ try { } // ----------------------------------------------------------------- status - async function status() { + async function status({ json = false } = {}) { + if (json) return statusJson(); const pidInfo = readPidFile(); const state = await healthIdentify(); console.log(`port: ${portOrNull()}`); @@ -880,12 +881,50 @@ try { } /** - * Connection details for manual client setup. `source` "running" is only - * passed by callers that verified /health answered as ours; model ids then - * come from the live /v1/models list, otherwise from the config file. The - * key is read, never generated or rotated here. + * `status --json`: one object on stdout and nothing else. Connection + * details only for the own proxy (running) or a stopped one (configured); + * a foreign listener or a missing key yields none. The key itself is never + * part of the JSON. Exit code as the text status: 0 only when ours. */ - async function connectionDetails(source) { + async function statusJson() { + const pidInfo = readPidFile(); + const state = await healthIdentify(); + const out = { + schemaVersion: 1, + port: portOrNull(), + pid: pidInfo ? { pid: pidInfo.pid, startedAt: pidInfo.startedIso ?? null, alive: pidAlive(pidInfo.pid) } : null, + health: state, + connection: null, + quota: null, + }; + if (state === "ours" || state === "down") { + let source = state === "ours" ? "running" : "configured"; + // Re-proven right before reporting, like the text output. + if (source === "running" && await healthIdentify(2500) !== "ours") source = "configured"; + try { + out.connection = connectionDetailsObject(await connectionFacts(source)); + } catch (err) { + out.connection = null; + out.connectionError = String(err?.message ?? err).slice(0, 200); + } + } + if (state === "ours") { + try { + const q = await fetch(`${base()}/quota`, { headers: { Authorization: `Bearer ${readKey()}` }, signal: AbortSignal.timeout(8000) }); + const j = await q.json().catch(() => null); + out.quota = q.ok && Array.isArray(j?.balances) + ? j.balances.map((b) => ({ name: String(b.showName ?? ""), remaining: b.remainingUnits ?? null, total: b.totalUnits ?? null, unit: b.unitType ?? null, expiresAt: Number.isFinite(b.expiresAt) ? new Date(b.expiresAt * 1000).toISOString() : null })) + : { error: `HTTP ${q.status}` }; + } catch { + out.quota = { error: "unavailable" }; + } + } + console.log(JSON.stringify(out, null, 2)); + return state === "ours" ? 0 : 1; + } + + /** Port, models (live list when the own proxy answers) and listener facts; never the key. */ + async function connectionFacts(source) { let models = configuredModelIds(CONFIG); let modelsSource = "config"; if (source === "running") { @@ -899,7 +938,18 @@ try { } } const server = configuredServer(CONFIG); - return connectionDetailsLines({ port: loadPort(), key: readKey(), models, modelsSource, source, reveal: shouldRevealKey(), host: server.host, responsesEnabled: server.responsesEnabled }); + return { port: loadPort(), models, modelsSource, source, host: server.host, responsesEnabled: server.responsesEnabled }; + } + + /** + * Connection details for manual client setup. `source` "running" is only + * passed by callers that verified /health answered as ours; model ids then + * come from the live /v1/models list, otherwise from the config file. The + * key is read, never generated or rotated here. + */ + async function connectionDetails(source) { + const facts = await connectionFacts(source); + return connectionDetailsLines({ ...facts, key: readKey(), reveal: shouldRevealKey() }); } async function printConnectionDetails(source) { @@ -1178,7 +1228,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] process.exit(code); break; } - case "status": process.exit(await m.status()); break; + case "status": process.exit(await m.status({ json: process.argv.includes("--json") })); break; case "doctor": process.exit(await m.doctor()); break; case "logs": process.exit(m.logs(Number(process.argv[3] ?? 40) || 40)); break; case "respawn": { diff --git a/tests/connection-details.test.mjs b/tests/connection-details.test.mjs index bc389b2..e1c7583 100644 --- a/tests/connection-details.test.mjs +++ b/tests/connection-details.test.mjs @@ -149,6 +149,50 @@ test("status on the own running proxy prints verified details with live model id assert.deepEqual({ text: readFileSync(keyFile, "utf8"), mtime: statSync(keyFile).mtimeMs }, before, "status never creates or rotates the key"); }); +test("status --json: one parseable object, redacted connection block for the own proxy, never the key", async (t) => { + server = await mockProxy(); + const port = server.address().port; + const root = kitRoot(port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const r = await capture(() => createManager({ root, home: root }).status({ json: true })); + assert.equal(r.code, 0); + const j = JSON.parse(r.text); // nothing but the object on stdout + assert.equal(j.schemaVersion, 1); + assert.equal(j.health, "ours"); + assert.equal(j.port, port); + assert.equal(j.connection.source, "running"); + assert.equal(j.connection.openaiBaseUrl, `http://127.0.0.1:${port}/v1`); + assert.equal(j.connection.anthropicBaseUrl, `http://127.0.0.1:${port}`); + assert.deepEqual(j.connection.models, ["mock-a", "mock-b"]); + assert.equal(j.connection.modelsSource, "live"); + assert.deepEqual(j.connection.key, { redacted: true, export: "zcode-kit models --show-key" }); + assert.deepEqual(j.quota, []); + assert.ok(!r.text.includes(KEY), "the key never appears in JSON"); +}); + +test("status --json: a stopped proxy reports configured values, a foreign port reports no connection", async (t) => { + const probe = await mockProxy(); const port = probe.address().port; probe.close(); + const root = kitRoot(port); + t.after(() => rmSync(root, { recursive: true, force: true })); + const down = await capture(() => createManager({ root, home: root }).status({ json: true })); + assert.equal(down.code, 1); + const d = JSON.parse(down.text); + assert.equal(d.health, "down"); + assert.equal(d.connection.source, "configured"); + assert.equal(d.connection.modelsSource, "config"); + assert.deepEqual(d.connection.models, ["glm-5.3", "glm-5.3-flash"]); + assert.equal(d.quota, null); + server = await mockProxy({ mode: "wrong-key" }); + const foreignRoot = kitRoot(server.address().port); + t.after(() => rmSync(foreignRoot, { recursive: true, force: true })); + const foreign = await capture(() => createManager({ root: foreignRoot, home: foreignRoot }).status({ json: true })); + assert.equal(foreign.code, 1); + const f = JSON.parse(foreign.text); + assert.equal(f.health, "foreign"); + assert.equal(f.connection, null); + assert.ok(!foreign.text.includes(KEY)); +}); + test("status on a stopped proxy labels configured values and never claims a running proxy", async (t) => { const probe = await mockProxy(); const port = probe.address().port; probe.close(); const root = kitRoot(port); diff --git a/tests/harness-consent.test.mjs b/tests/harness-consent.test.mjs index 7321bc8..3c8de74 100644 --- a/tests/harness-consent.test.mjs +++ b/tests/harness-consent.test.mjs @@ -12,7 +12,7 @@ import { join } from "node:path"; import { execFile, spawn, spawnSync } from "node:child_process"; import { ABORTED, HARNESS_CHOICES_DIR, HARNESS_SELECTION_ENV, NO_CONSENT_HINT, askYesNo, consentStatus, consentedForRepair, - createPrompter, decideHarness, harnessQuestion, readHarnessChoices, recordHarnessChoice, resolveHarnessSelection, + createPrompter, decideHarness, harnessQuestion, parseSelection, readHarnessChoices, recordHarnessChoice, resolveHarnessSelection, } from "../cli/harness-consent.mjs"; import { ACCOUNT_ROTATOR_QUESTION, askAccountRotator } from "../cli/account-setup.mjs"; import { beginTransaction, rollbackTransaction } from "../lib/transaction.mjs"; @@ -573,6 +573,62 @@ test("--no-mcp with an explicit selection records provider consent only; later r assert.equal(after.mcp, true, "integrate neither grants nor revokes the MCP consent recorded earlier"); }); +test("parseSelection: numbers, all, none; anything else is asked again", () => { + assert.deepEqual([...parseSelection("1,3", 3)], [0, 2]); + assert.deepEqual([...parseSelection(" 2 1 ", 3)].sort(), [0, 1]); + assert.deepEqual([...parseSelection("ALL", 2)], [0, 1]); + assert.equal(parseSelection("none", 2).size, 0); + for (const bad of ["", "0", "4", "1,x", "1-2", "y"]) assert.equal(parseSelection(bad, 3), undefined, bad); +}); + +test("--select without a terminal or next to an explicit selection changes nothing", async (t) => { + const f = fixture(t); + const r = await run(f, ["setup", "--select"]); + assert.equal(r.code, 0, r.text); + assert.match(r.stdout, /--select needs an interactive terminal — ignored/); + assert.match(r.stdout, /\[SKIP\] OMP \/ Oh My Pi — no interactive consent/); + assert.equal(choicesOf(f), null); + const e = await run(f, ["setup", "--select", "--harness", "pi"]); + assert.equal(e.code, 0, e.text); + assert.match(e.stdout, /--select ignored — the explicit selection \(--harness\) decides/); + assert.deepEqual(choicesOf(f), { pi: "configured/flag" }); +}); + +test("doctor reports decision files (unreadable = FAIL with the way out, undetected/unknown = SKIP); --forget removes one through a transaction", async (t) => { + const f = fixture(t); + writeChoice(f, "omp", "configured"); + writeChoice(f, "codex", "skipped"); // not detected in this fixture + writeFileSync(f.choice("pi"), "{ broken"); + writeFileSync(f.choice("mystery"), JSON.stringify({ schema: 1, harness: "mystery", decision: "skipped", source: "x", decidedAt: "" })); + const doctor = await run(f, ["doctor", "--json"]); + const checks = JSON.parse(doctor.stdout.slice(doctor.stdout.indexOf("{"))).checks; + const by = (name) => checks.find((c) => c.name === name); + assert.equal(by("pi: decision file")?.ok, false, "an unreadable decision is a failure"); + assert.match(by("pi: decision file").detail, /zcode-kit doctor --forget pi/); + assert.equal(by("codex: decision file")?.ok, null); + assert.match(by("codex: decision file").detail, /not detected/); + assert.equal(by("mystery: decision file")?.ok, null); + assert.equal(by("omp: decision file"), undefined, "a readable decision of a detected harness needs no line"); + const piBefore = readFileSync(f.pi, "utf8"); + const brokenBytes = readFileSync(f.choice("pi")); + const forget = await run(f, ["doctor", "--forget", "pi"]); + assert.equal(forget.code, 0, forget.text); + assert.match(forget.stdout, /Decision for pi removed\. Its integration is unchanged; the next zcode-kit setup asks again\./); + assert.equal(existsSync(f.choice("pi")), false); + assert.equal(readFileSync(f.pi, "utf8"), piBefore, "the integration itself is never touched"); + const tx = forget.stdout.match(/undo with: zcode-kit rollback (\S+)/)?.[1]; + assert.ok(tx, forget.stdout); + const rb = await run(f, ["rollback", tx]); + assert.equal(rb.code, 0, rb.text); + assert.deepEqual(readFileSync(f.choice("pi")), brokenBytes, "rollback restores the file byte for byte"); + assert.equal((await run(f, ["doctor", "--forget", "goose"])).code, 0, "no decision: nothing to forget"); + const unknown = await run(f, ["doctor", "--forget", "nonsense"]); + assert.equal(unknown.code, 2); + assert.equal((await run(f, ["doctor", "--forget"])).code, 2, "a missing id changes nothing"); + assert.equal((await run(f, ["doctor", "--forget", "mystery"])).code, 0, "a stored unknown id can be removed"); + assert.equal(existsSync(f.choice("mystery")), false); +}); + test("rolling back a setup removes the decisions it recorded together with the integration", async (t) => { const f = fixture(t); const r = await run(f, ["setup", "--harness", "omp"]); @@ -649,6 +705,28 @@ test("interactive setup asks per harness and applies the answers", { skip: !scri assert.deepEqual(choicesOf(f), { omp: "skipped/interactive", pi: "configured/interactive" }); }); +test("setup --select: one numbered list, invalid answers repeat, chosen = configured, the rest = skipped, no per-harness question", { skip: !script }, async (t) => { + const f = fixture(t); + const r = await runPty(f, ["setup", "--select"], [ + { after: "(numbers, all, none)", send: "7\n" }, + { after: "Please enter numbers from the list", send: "2\n" }, + { after: "[y/n]", send: "n\n" }, + ]); + assert.equal(r.code, 0, `${r.out}\n${r.stderr}`); + assert.match(r.out, /1\) OMP \/ Oh My Pi\s*\n\s*2\) pi/); + assert.match(r.out, /note: selecting OMP \/ Oh My Pi also registers the kit's MCP bridge/); + assert.doesNotMatch(r.out, /in OMP \/ Oh My Pi\? \[y\/n\]/, "no per-harness question"); + assert.match(r.out, /\[SKIP\] OMP \/ Oh My Pi — answered n/); + assert.match(r.out, /\[OK\]\s+pi/); + assert.deepEqual(choicesOf(f), { omp: "skipped/interactive", pi: "configured/interactive" }); + assert.equal(existsSync(f.mcp), false, "the bridge only for a chosen harness"); + const all = await runPty(f, ["setup", "--select"], [{ after: "(numbers, all, none)", send: "all\n" }, { after: "[y/n]", send: "n\n" }]); + assert.equal(all.code, 0, `${all.out}\n${all.stderr}`); + assert.match(all.out, /1\) OMP \/ Oh My Pi \(currently skipped\)/, "stored decisions are shown"); + assert.deepEqual(choicesOf(f), { omp: "configured/interactive", pi: "configured/interactive" }); + assert.ok(existsSync(f.mcp), "chosen after the MCP note: bridge registered"); +}); + test("Ctrl-C during the questions stops setup: answered harnesses stay, the rest is skipped, exit 130", { skip: !script }, async (t) => { const f = fixture(t); const piBefore = readFileSync(f.pi, "utf8"); diff --git a/tests/installer-audit.test.mjs b/tests/installer-audit.test.mjs index 4bcb302..2ee953c 100644 --- a/tests/installer-audit.test.mjs +++ b/tests/installer-audit.test.mjs @@ -17,7 +17,7 @@ function executable(path, body) { chmodSync(path, 0o755); } -function runSh({ bin, home, install, temp, log, version = "v1.2.3" }) { +function runSh({ bin, home, install, temp, log, version = "v1.2.3", extraEnv = {} }) { return spawnSync("sh", ["-c", ` if command -v cygpath >/dev/null 2>&1; then FIXTURE_BIN=$(cygpath -u "$FIXTURE_BIN") @@ -41,6 +41,7 @@ exec sh "$1" TMPDIR: temp, AUDIT_LOG: log, ZCODE_KIT_VERSION: version, + ...extraEnv, }, }); } @@ -170,6 +171,186 @@ printf '// fixture\\n' > "$out/kit/cli/zcode-kit.mjs"`); } }); +// Repeat install over an existing installation: a real release tarball, real +// tar/find/sha256, fake download/node/bun. Machine-local state (key, user +// config, Bun path, node_modules/backups/logs/generated at any depth) must +// survive byte for byte; files the release no longer ships must go. +for (const mode of ["portable", "default"]) { + test(`install.sh repeat install (${mode === "portable" ? "portable mirror, no rsync" : "rsync when present, else portable"}) keeps local state and removes stale files`, { skip: process.platform === "win32" }, () => { + const root = mkdtempSync(join(tmpdir(), `zcode-installer-mirror-${mode}-`)); + const bin = join(root, "bin"); + const home = join(root, "home"); + const install = join(root, "target"); + const temp = join(root, "tmp"); + const log = join(root, "curl.log"); + const release = join(root, "release"); + try { + for (const dir of [bin, home, temp, release]) mkdirSync(dir, { recursive: true }); + const kit = join(root, "build", "zcode-agent-kit-1.2.3"); + const files = { + "package.json": '{"name":"zcode-agent-kit"}\n', + "cli/zcode-kit.mjs": "// release v2\n", + "proxy/config.example.yaml": "port: 1\n", + "docs/new file.md": "added in v2\n", + }; + for (const [rel, body] of Object.entries(files)) { mkdirSync(join(kit, rel, ".."), { recursive: true }); writeFileSync(join(kit, rel), body); } + assert.equal(spawnSync("tar", ["-czf", join(release, "v1.2.3.tar.gz"), "-C", join(root, "build"), "zcode-agent-kit-1.2.3"]).status, 0); + const sha = createHash("sha256").update(readFileSync(join(release, "v1.2.3.tar.gz"))).digest("hex"); + writeFileSync(join(release, "checksums.txt"), `${sha} v1.2.3.tar.gz\n`); + executable(join(bin, "curl"), `out=''; url='' +while [ "$#" -gt 0 ]; do case "$1" in -o) out="$2"; shift 2 ;; -*) shift ;; *) url="$1"; shift ;; esac; done +printf '%s\\n' "$url" >> "$AUDIT_LOG" +case "$url" in + */v1.2.3.tar.gz) cp "$RELEASE_DIR/v1.2.3.tar.gz" "$out" ;; + */checksums.txt) cp "$RELEASE_DIR/checksums.txt" "$out" ;; + *) exit 83 ;; +esac`); + executable(join(bin, "node"), `if [ "\${1:-}" = --version ]; then printf 'v20.19.0\\n'; exit 0; fi +[ "\${1:-}" = cli/zcode-kit.mjs ] && [ "\${2:-}" = setup ] || exit 81 +printf 'setup ran\\n'`); + executable(join(bin, "bun"), "printf '1.4.2\\n'"); + const existing = { + "package.json": '{"name":"zcode-agent-kit"}\n', + "cli/zcode-kit.mjs": "// release v1\n", + "stale.txt": "removed in v2\n", + "old dir/inner.txt": "removed in v2\n", + ".proxykey": "local-secret-key\n", + "proxy/config.yaml": "user: settings\n", + "logs/proxy.log": "history\n", + "backups/tx-1.json": "{}\n", + "generated/harness-choices/omp.json": '{"schema":1}\n', + "node_modules/dep/config.yaml": "dep\n", + "zcode-proxy-src/node_modules/x/index.js": "x\n", + }; + for (const [rel, body] of Object.entries(existing)) { mkdirSync(join(install, rel, ".."), { recursive: true }); writeFileSync(join(install, rel), body); } + const result = runSh({ bin, home, install, temp, log, extraEnv: { RELEASE_DIR: release, ...(mode === "portable" ? { ZCODE_KIT_MIRROR: "portable" } : {}) } }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Updating existing installation; keeping your configuration/); + assert.equal(readFileSync(join(install, "cli/zcode-kit.mjs"), "utf8"), "// release v2\n", "the release arrives"); + assert.equal(readFileSync(join(install, "docs/new file.md"), "utf8"), "added in v2\n"); + assert.equal(existsSync(join(install, "stale.txt")), false, "a file the release dropped is removed"); + assert.equal(existsSync(join(install, "old dir")), false, "a directory the release dropped is removed"); + for (const rel of [".proxykey", "proxy/config.yaml", "logs/proxy.log", "backups/tx-1.json", "generated/harness-choices/omp.json", "node_modules/dep/config.yaml", "zcode-proxy-src/node_modules/x/index.js"]) { + assert.equal(readFileSync(join(install, rel), "utf8"), existing[rel], `${rel} is machine-local state and survives`); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +/** + * install.ps1 against mocked downloads/tar/node/bun (real robocopy). `release` + * are the files the mocked tar extracts; `setupExit` is what the mocked setup + * returns. Returns the spawn result plus the install dir. + */ +function runPs1Fixture(root, { release, setupExit = 0, preexisting = {} }) { + const bin = join(root, "bin"); + const local = join(root, "Local AppData"); + const install = join(root, "kit"); + const temp = join(root, "temp"); + const fakeSystem = join(root, "system"); + const wrapper = join(root, "fixture.ps1"); + const archive = "fixture archive\n"; + const hash = createHash("sha256").update(archive).digest("hex"); + for (const dir of [bin, local, temp, fakeSystem]) mkdirSync(dir, { recursive: true }); + for (const [rel, body] of Object.entries(preexisting)) { mkdirSync(join(install, rel, ".."), { recursive: true }); writeFileSync(join(install, rel), body); } + writeFileSync(join(bin, "bun.cmd"), "@echo off\r\necho 1.4.2\r\nexit /b 0\r\n"); + const writes = Object.entries(release).map(([rel, body]) => + ` New-Item -ItemType Directory -Path (Split-Path (Join-Path $out '${`kit\\${rel.replaceAll("/", "\\")}`.replaceAll("'", "''")}') -Parent) -Force | Out-Null\n` + + ` [IO.File]::WriteAllText((Join-Path $out '${`kit\\${rel.replaceAll("/", "\\")}`.replaceAll("'", "''")}'), '${body.replaceAll("'", "''")}')`).join("\n"); + writeFileSync(wrapper, `param([string]$Installer) +$ErrorActionPreference = 'Continue' +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing) + $utf8 = New-Object System.Text.UTF8Encoding($false) + if ($Uri -like '*/v1.2.3.tar.gz') { [IO.File]::WriteAllText($OutFile, "fixture archive\n", $utf8); return } + if ($Uri -like '*/checksums.txt') { [IO.File]::WriteAllText($OutFile, '${hash} v1.2.3.tar.gz' + "\n", $utf8); return } + throw "unexpected URL $Uri" +} +function node { + if ($args[0] -eq '--version') { 'v20.19.0'; $global:LASTEXITCODE = 0; return } + if ($args[0] -ne 'cli/zcode-kit.mjs' -or $args[1] -ne 'setup') { throw 'unexpected node invocation' } + 'setup stub' + $global:LASTEXITCODE = ${setupExit} +} +function bun { '1.4.2'; $global:LASTEXITCODE = 0 } +function tar { + $idx = [Array]::IndexOf($args, '-C') + if ($idx -lt 0) { $global:LASTEXITCODE = 91; return } + $out = $args[$idx + 1] +${writes} + $global:LASTEXITCODE = 0 +} +$env:LOCALAPPDATA = '${local.replaceAll("'", "''")}' +$env:TEMP = '${temp.replaceAll("'", "''")}' +$env:SystemRoot = '${fakeSystem.replaceAll("'", "''")}' +$env:ZCODE_KIT_INSTALL_DIR = '${install.replaceAll("'", "''")}' +$env:ZCODE_KIT_VERSION = 'v1.2.3' +$env:PATH = '${bin.replaceAll("'", "''")};' + $env:PATH +& $Installer +$rc = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 } +exit $rc +`, "utf8"); + const result = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", wrapper, INSTALL_PS1], { encoding: "utf8", timeout: 30_000 }); + return { result, install }; +} +const PS1_RELEASE = { "package.json": '{"name":"zcode-agent-kit"}', "cli/zcode-kit.mjs": "// release v2", "zcode-proxy-src/package.json": "{}" }; +const pwshAvailable = () => process.platform === "win32" && spawnSync("pwsh", ["-NoProfile", "-Command", "1"], { encoding: "utf8" }).status === 0; + +test("install.ps1 repeat install (robocopy /MIR): local state survives, files the release dropped are removed", { skip: process.platform !== "win32" }, (t) => { + if (!pwshAvailable()) return t.skip("pwsh is unavailable"); + const root = mkdtempSync(join(tmpdir(), "zcode-installer-ps-mirror-")); + try { + const preexisting = { + "package.json": '{"name":"zcode-agent-kit"}', + "cli/zcode-kit.mjs": "// release v1", + "stale.txt": "removed in v2", + ".proxykey": "local-secret-key", + "proxy/config.yaml": "user: settings", + "logs/proxy.log": "history", + "backups/tx-1.json": "{}", + "generated/harness-choices/omp.json": "{}", + "zcode-proxy-src/node_modules/x/index.js": "x", + }; + const { result, install } = runPs1Fixture(root, { release: PS1_RELEASE, preexisting }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Updating existing installation; keeping your configuration/); + assert.equal(readFileSync(join(install, "cli", "zcode-kit.mjs"), "utf8"), "// release v2"); + assert.equal(existsSync(join(install, "stale.txt")), false, "a file the release dropped is removed"); + for (const rel of [".proxykey", "proxy/config.yaml", "logs/proxy.log", "backups/tx-1.json", "generated/harness-choices/omp.json", "zcode-proxy-src/node_modules/x/index.js"]) { + assert.equal(readFileSync(join(install, ...rel.split("/")), "utf8"), preexisting[rel], `${rel} survives`); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("install.ps1 treats setup exit 20 and 130 as installed-with-warning and any other failure as failure", { skip: process.platform !== "win32" }, (t) => { + if (!pwshAvailable()) return t.skip("pwsh is unavailable"); + for (const [setupExit, expectOk, message] of [ + [20, true, /some assistants could not be configured/], + [130, true, /setup was interrupted; the kit is installed/], + [1, false, /setup failed \(exit 1\)/], + ]) { + const root = mkdtempSync(join(tmpdir(), `zcode-installer-ps-exit${setupExit}-`)); + try { + const { result, install } = runPs1Fixture(root, { release: PS1_RELEASE, setupExit }); + const text = `${result.stdout}\n${result.stderr}`; + assert.match(text, message, `exit ${setupExit}\n${text}`); + if (expectOk) { + assert.equal(result.status, 0, `exit ${setupExit}\n${text}`); + assert.ok(existsSync(join(root, "Local AppData", "Microsoft", "WindowsApps", "zcode-kit.cmd")), "the shim is installed"); + } else { + assert.notEqual(result.status, 0, text); + } + assert.ok(existsSync(join(install, "cli", "zcode-kit.mjs"))); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } +}); + test("install.ps1 executes hermetically with mocked downloads/setup and writes a Unicode-safe shim", { skip: process.platform !== "win32" }, (t) => { const probe = spawnSync("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], { encoding: "utf8" }); if (probe.status !== 0) return t.skip("pwsh is unavailable"); diff --git a/tests/upstream-config.test.mjs b/tests/upstream-config.test.mjs new file mode 100644 index 0000000..d1fe2ed --- /dev/null +++ b/tests/upstream-config.test.mjs @@ -0,0 +1,163 @@ +// `doctor --upstream`: comparison of the kit's gateway with the provider +// configuration the ZCode client receives, both answer shapes, network +// boundaries, and drift between the kit table and the proxy's constants. +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, cpSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { + KIT_GATEWAY_BASES, REFERENCE_APP_VERSION, UPSTREAM_ORIGIN_ENV, compareUpstream, fetchUpstreamProviders, readProxyTarget, + resolveUpstreamOrigin, upstreamChecks, +} from "../cli/upstream-config.mjs"; + +const KIT = join(import.meta.dirname, ".."); +let server = null; +afterEach(() => { if (server) { server.closeAllConnections?.(); server.close(); server = null; } }); + +function rule(mode, accountType, baseUrl, models) { + return { providerId: `account:${accountType}-${mode}`, config: { access: { type: "zhipu-account", mode, accountType }, api: { type: "anthropic-messages", baseUrl }, builtinModelIds: models } }; +} +const RELEASE = { + schemaVersion: 1, revision: 23, + config: { + providerConfigRules: { + templateRules: [], + providerRules: [ + rule("individual-coding-plan", "zai", "https://api.z.ai/api/anthropic", ["GLM-5.3", "GLM-5.3-Flash"]), + rule("start-plan", "zai", "https://zcode.z.ai/api/v1/zcode-plan/anthropic", ["GLM-5.3-Flash", "GLM-5.2"]), + rule("individual-coding-plan", "bigmodel", "https://open.bigmodel.cn/api/anthropic", ["GLM-5.3"]), + ], + }, + modelConfigRules: {}, + }, +}; +const LEGACY = { code: 0, msg: "", data: { providers: [ + { id: "z-ai", schema: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: [{ modelId: "GLM-5.2" }] }, + { id: "z-ai", schema: "openai:chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: [] }, +], configs: {} } }; + +/** Loopback vendor double: `routes(appVersion)` picks the answer; every request is recorded. */ +async function vendor(routes) { + const requests = []; + server = http.createServer((req, res) => { + const url = new URL(req.url, "http://x"); + requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), headers: req.headers }); + const origin = `http://127.0.0.1:${server.address().port}`; + const body = routes(url, origin); + if (body === "redirect") { res.writeHead(302, { location: "https://example.org/x" }).end(); return; } + if (body === "hang") return; + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body)); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + return { origin: `http://127.0.0.1:${server.address().port}`, requests }; +} +const releaseRoutes = (url, origin) => url.pathname === "/release.json" ? RELEASE + : { code: 0, data: { configs: { builtin_provider_config_json: `${origin}/release.json` } } }; +function config(dir, { provider = "zai", plan = "start-plan", appVersion = "3.14.3", models = ["glm-5.3", "glm-5.3-flash"] } = {}) { + const path = join(dir, "config.yaml"); + writeFileSync(path, `provider: ${provider}\nplan: ${plan}\nmodels:\n${models.map((m) => ` - ${m}\n`).join("")}identity:\n appVersion: "${appVersion}"\n`); + return path; +} + +test("the kit's gateway table matches the proxy's own constants (no silent drift)", () => { + const providers = readFileSync(join(KIT, "zcode-proxy-src", "src", "provider", "providers.ts"), "utf8"); + const upstream = readFileSync(join(KIT, "zcode-proxy-src", "src", "proxy", "upstream.ts"), "utf8"); + const anthropicBase = (id) => providers.match(new RegExp(`id: "${id}",[\\s\\S]*?anthropicBaseURL: "([^"]+)"`))?.[1]; + assert.equal(KIT_GATEWAY_BASES["coding-plan"].zai, anthropicBase("zai")); + assert.equal(KIT_GATEWAY_BASES["coding-plan"].bigmodel, anthropicBase("bigmodel")); + const startPlan = upstream.match(/const STARTPLAN_ANTHROPIC_BASE = "([^"]+)"/)?.[1]; + assert.ok(startPlan, "STARTPLAN_ANTHROPIC_BASE found"); + assert.match(upstream, /STARTPLAN_ANTHROPIC_BASE\}\/anthropic/, "start-plan requests go to /anthropic"); + for (const provider of ["zai", "bigmodel"]) assert.equal(KIT_GATEWAY_BASES["start-plan"][provider], `${startPlan}/anthropic`); +}); + +test("readProxyTarget reads provider, plan, identity.appVersion and models", (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-up-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + assert.deepEqual(readProxyTarget(config(dir, { provider: "bigmodel", plan: "coding-plan", appVersion: "3.9.0", models: ["glm-5.3"] })), + { provider: "bigmodel", plan: "coding-plan", appVersion: "3.9.0", models: ["glm-5.3"] }); + const defaults = readProxyTarget(join(dir, "missing.yaml")); + assert.equal(defaults.plan, "start-plan"); + assert.equal(defaults.provider, "zai"); +}); + +test("release shape: matching base PASS, moved base FAIL with the new URL, models informational; no credentials, platform and version sent", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-up-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const v = await vendor(releaseRoutes); + const env = { [UPSTREAM_ORIGIN_ENV]: v.origin }; + const pass = await upstreamChecks(config(dir, { plan: "start-plan" }), { env }); + assert.equal(pass[0].ok, true, JSON.stringify(pass)); + assert.match(pass[0].detail, /remote provider release rev 23/); + assert.equal(pass[1].ok, null, "glm-5.3 is not offered for start-plan: informational"); + assert.match(pass[1].detail, /glm-5\.3\b/); + assert.equal(v.requests[0].path, "/api/v1/client/configs"); + assert.deepEqual(v.requests[0].query, { app_version: "3.14.3", platform: "win32-x64" }); + for (const r of v.requests) assert.equal(r.headers.authorization, undefined); + const moved = compareUpstream({ provider: "zai", plan: "coding-plan", appVersion: "x", models: ["glm-5.3"] }, + { shape: "release", revision: 24, entries: [{ provider: "zai", mode: "individual-coding-plan", baseUrl: "https://api.z.ai/api/v2/anthropic", models: ["GLM-5.3"] }] }); + assert.equal(moved[0].ok, false); + assert.match(moved[0].detail, /now uses https:\/\/api\.z\.ai\/api\/v2\/anthropic/); + assert.equal(moved[1].ok, true, "model ids compare case-insensitively"); +}); + +test("legacy shape: coding-plan compared from the provider list; start-plan falls back to the reference client version", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-up-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const v = await vendor((url, origin) => url.searchParams.get("app_version") === REFERENCE_APP_VERSION ? releaseRoutes(url, origin) : url.pathname === "/release.json" ? RELEASE : LEGACY); + const env = { [UPSTREAM_ORIGIN_ENV]: v.origin }; + const coding = await upstreamChecks(config(dir, { plan: "coding-plan", appVersion: "3.11.2" }), { env }); + assert.equal(coding[0].ok, true); + assert.match(coding[0].detail, /provider list/); + const start = await upstreamChecks(config(dir, { plan: "start-plan", appVersion: "3.11.2" }), { env }); + assert.equal(start[0].ok, true, JSON.stringify(start)); + assert.match(start[0].detail, /as served to app 3\.14\.3; the kit announces 3\.11\.2/); +}); + +test("network boundaries: redirects, foreign release hosts, stalls and junk are SKIPs; non-loopback http origins are refused", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "kit-up-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const cfg = config(dir); + for (const answer of ["redirect", { code: 0, data: { configs: { builtin_provider_config_json: "https://evil.example.org/r.json" } } }, { code: 3001, msg: "parameter error" }, { nonsense: true }]) { + const v = await vendor(() => answer); + const checks = await upstreamChecks(cfg, { env: { [UPSTREAM_ORIGIN_ENV]: v.origin } }); + assert.equal(checks.length, 1); + assert.equal(checks[0].ok, null, JSON.stringify(checks)); + assert.match(checks[0].detail, /unreachable or unreadable/); + assert.ok(!v.requests.some((r) => r.path === "/x"), "a redirect is never followed"); + server.close(); server = null; + } + const hang = await vendor(() => "hang"); + const started = Date.now(); + const stalled = await upstreamChecks(cfg, { env: { [UPSTREAM_ORIGIN_ENV]: hang.origin }, timeoutMs: 300 }); + assert.equal(stalled[0].ok, null); + assert.ok(Date.now() - started < 5000, "bounded"); + assert.throws(() => resolveUpstreamOrigin({ [UPSTREAM_ORIGIN_ENV]: "http://zcode.example.org" }), /https origin/); + assert.equal(resolveUpstreamOrigin({}), "https://zcode.z.ai"); + await assert.rejects(fetchUpstreamProviders({ appVersion: "1", origin: "http://127.0.0.1:9", fetchImpl: async () => { throw new Error("offline"); } }), /offline/); +}); + +test("the default doctor never calls the vendor; --upstream does", async (t) => { + const root = mkdtempSync(join(tmpdir(), "kit-up-e2e-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const member of ["cli", "lib"]) cpSync(join(KIT, member), join(root, member), { recursive: true }); + mkdirSync(join(root, "proxy")); mkdirSync(join(root, "logs")); mkdirSync(join(root, "zcode-proxy-src")); + cpSync(join(KIT, "proxy", "zcode-proxy-manager.mjs"), join(root, "proxy", "zcode-proxy-manager.mjs")); + cpSync(join(KIT, "zcode-proxy-src", "package.json"), join(root, "zcode-proxy-src", "package.json")); + symlinkSync(join(KIT, "zcode-proxy-src", "node_modules"), join(root, "zcode-proxy-src", "node_modules"), process.platform === "win32" ? "junction" : "dir"); + config(join(root, "proxy")); + const home = join(root, "home"); mkdirSync(home); + const v = await vendor(releaseRoutes); + const env = { ...process.env, HOME: home, USERPROFILE: home, [UPSTREAM_ORIGIN_ENV]: v.origin, ZCODE_KIT_SKIP_DEPS: "1", ZCODE_PROXY_CREDENTIALS_PATH: join(home, "c.json") }; + const run = (args) => new Promise((resolve) => execFile(process.execPath, [join(root, "cli", "zcode-kit.mjs"), ...args], { env, encoding: "utf8", timeout: 60000 }, + (err, stdout) => resolve({ code: err ? err.code : 0, stdout }))); + await run(["doctor", "--json"]); + assert.equal(v.requests.length, 0, "no vendor request without --upstream"); + const r = await run(["doctor", "--json", "--upstream"]); + const checks = JSON.parse(r.stdout.slice(r.stdout.indexOf("{"))).checks; + assert.ok(checks.some((c) => c.name === "upstream gateway (zai, start-plan)" && c.ok === true), r.stdout); + assert.ok(v.requests.length >= 2); +}); diff --git a/zcode-proxy-src/README.de.md b/zcode-proxy-src/README.de.md index 0ce63cf..4eba09f 100644 --- a/zcode-proxy-src/README.de.md +++ b/zcode-proxy-src/README.de.md @@ -56,7 +56,7 @@ nur Metadaten. Die lokale Korrektur dekodiert gzip, deflate und Brotli vor der Auswertung übersetzter Streams oder JSON-Fehlerantworten. Leere oder nicht dekodierbare Batch-Antworten werden als `upstream_invalid_response` gemeldet, nicht als erfolgreiche leere Antworten. Der Proxy ersetzt das Arbeitsverzeichnis des aufrufenden Harness nicht durch sein eigenes; `ZCODE_IDENTITY_ENV_CWD` bleibt ein ausdrücklicher Override. -Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 sowie die Gateway-Fehlercodes, die der offizielle Client wiederholt) werden bis zu dreimal auf demselben Konto mit wachsender Wartezeit wiederholt, mit kleinerem Budget als beim offiziellen Client (Basiswartezeit 1 s, verdoppelt; eingestellt über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen); das Konto wird vor jeder Wiederholung erneut geprüft, ein `Retry-After` wird bis 15 Sekunden beachtet und darüber an den Client durchgereicht, und Gateway-Entscheidungen (Kontingent, Guthaben, Captcha, Modell, Authentifizierung), Anfragefehler und alles nach begonnener Ausgabe werden nie wiederholt. Auf dem geordneten Transport für erzwungene Client-Sitzungen wird auch ein Fehler nach vollständig geschriebener Anfrage nicht wiederholt. Ein nativer Anthropic-Stream, der ohne `message_stop` endet, erhält einen abschließenden `event: error`-Frame vom Typ `api_error` (die Meldung nennt `upstream_incomplete`, bei fehlgeschlagenem Lesen `upstream_stream_error`); ein unvollständiger letzter Frame wird verworfen, damit Clients einen lesbaren Fehler statt einer stillen Kürzung sehen. +Vorübergehende Fehler vor jeder Ausgabe (Verbindung abgelehnt oder zurückgesetzt, HTTP 500/502/503/504/524/529, 429 sowie die Gateway-Fehlercodes, die der offizielle Client wiederholt) werden bis zu dreimal auf demselben Konto mit wachsender Wartezeit wiederholt, mit kleinerem Budget als beim offiziellen Client (Basiswartezeit 1 s, verdoppelt; eingestellt über `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: Basiswartezeit in Millisekunden, Standard 500, maximal 10000; `off` behält nur die Wiederholung nie zustande gekommener Verbindungen); das Konto wird vor jeder Wiederholung erneut geprüft, ein `Retry-After` wird bis 15 Sekunden beachtet und darüber an den Client durchgereicht, und Gateway-Entscheidungen (Kontingent, Guthaben, Captcha, Modell, Authentifizierung), Anfragefehler und alles nach begonnener Ausgabe werden nie wiederholt. Ein Stream, der vor seinem ersten Inhalts-Event scheitert (ein Fehler-Event wie `overloaded_error` oder eine abgebrochene Verbindung), wird ebenso wiederholt; der Client erhält die Antwort-Header dann, sobald das erste Inhalts-Event da ist (höchstens 15 Sekunden später). Beim Start-Plan holt jede Wiederholung nach einer Antwort oder einem Verbindungsabbruch ein frisches Captcha-Token. Der geordnete Transport für erzwungene Client-Sitzungen wiederholt eine vor der Antwort abgebrochene Verbindung wie der Fetch-Pfad (dasselbe Konto; der Kontingent-Failover spielt sie nie auf einem anderen Konto erneut ab). Ein nativer Anthropic-Stream, der ohne `message_stop` endet, erhält einen abschließenden `event: error`-Frame vom Typ `api_error` (die Meldung nennt `upstream_incomplete`, bei fehlgeschlagenem Lesen `upstream_stream_error`); ein unvollständiger letzter Frame wird verworfen, damit Clients einen lesbaren Fehler statt einer stillen Kürzung sehen. ## Sicherheit diff --git a/zcode-proxy-src/README.es.md b/zcode-proxy-src/README.es.md index 8114dd4..80e9e4b 100644 --- a/zcode-proxy-src/README.es.md +++ b/zcode-proxy-src/README.es.md @@ -56,7 +56,7 @@ depuración contienen únicamente metadatos. La corrección local decodifica gzip, deflate y Brotli antes de interpretar los flujos traducidos o las respuestas de error JSON. Los cuerpos vacíos o imposibles de decodificar se notifican como `upstream_invalid_response`, no como respuestas vacías correctas. El proxy no sustituye el directorio del asistente por el de su proceso; `ZCODE_IDENTITY_ENV_CWD` sigue siendo una anulación explícita. -Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 y los códigos de error del gateway que el cliente oficial reintenta) se reintentan hasta tres veces en la misma cuenta con una espera creciente, con un presupuesto menor que el del cliente oficial (espera base de 1 s que se duplica, fijada por `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones nunca establecidas); la cuenta se vuelve a comprobar antes de cada reintento, un `Retry-After` se respeta hasta 15 segundos y por encima se pasa al cliente, y los veredictos del gateway (cuota, saldo, captcha, modelo, autenticación), los errores de la solicitud y cualquier cosa tras el inicio de la salida nunca se reintentan. En el transporte ordenado de las sesiones de cliente forzadas, un fallo posterior a la escritura completa de la solicitud tampoco se reintenta. Un flujo Anthropic nativo que termina sin `message_stop` recibe un frame final `event: error` de tipo `api_error` (su mensaje indica `upstream_incomplete`, o `upstream_stream_error` si falló la lectura); un último frame incompleto se descarta para que los clientes vean un fallo legible en lugar de un truncamiento silencioso. +Los fallos transitorios antes de cualquier salida (conexión rechazada o reiniciada, HTTP 500/502/503/504/524/529, 429 y los códigos de error del gateway que el cliente oficial reintenta) se reintentan hasta tres veces en la misma cuenta con una espera creciente, con un presupuesto menor que el del cliente oficial (espera base de 1 s que se duplica, fijada por `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: espera base en milisegundos, 500 por defecto, máximo 10000; `off` conserva solo el reintento de conexiones nunca establecidas); la cuenta se vuelve a comprobar antes de cada reintento, un `Retry-After` se respeta hasta 15 segundos y por encima se pasa al cliente, y los veredictos del gateway (cuota, saldo, captcha, modelo, autenticación), los errores de la solicitud y cualquier cosa tras el inicio de la salida nunca se reintentan. Un flujo que falla antes de su primer evento de contenido (un evento de error como `overloaded_error` o una conexión cortada) se reintenta igual; el cliente recibe entonces las cabeceras de la respuesta cuando llega el primer evento de contenido (como mucho 15 segundos después). En start-plan, cada reintento tras una respuesta o una conexión cortada usa un token CAPTCHA nuevo. El transporte ordenado de las sesiones de cliente forzadas reintenta una conexión cortada antes de la respuesta igual que la ruta fetch (misma cuenta; la conmutación por cuota nunca la repite en otra cuenta). Un flujo Anthropic nativo que termina sin `message_stop` recibe un frame final `event: error` de tipo `api_error` (su mensaje indica `upstream_incomplete`, o `upstream_stream_error` si falló la lectura); un último frame incompleto se descarta para que los clientes vean un fallo legible en lugar de un truncamiento silencioso. ## Seguridad diff --git a/zcode-proxy-src/README.ja.md b/zcode-proxy-src/README.ja.md index eabaf4c..1acf369 100644 --- a/zcode-proxy-src/README.ja.md +++ b/zcode-proxy-src/README.ja.md @@ -52,7 +52,7 @@ CAPTCHA 成功を証明するものでもありません。従来の診断用バ ローカル修正では、変換対象のストリームや JSON エラー応答を解釈する前に gzip、deflate、Brotli を展開します。空または展開できないバッチ応答は、空の成功応答ではなく `upstream_invalid_response` として通知します。プロキシは自身の作業ディレクトリを呼び出し元のワークスペースとして提示しません。`ZCODE_IDENTITY_ENV_CWD` は明示的な上書きとして維持されます。 -出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、429、公式クライアントが再試行するゲートウェイのエラーコード)は、同じアカウントで待ち時間を増やしながら最大 3 回再試行します。予算は公式クライアントより小さく(基本待ち時間 1 秒で倍増、`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で設定:基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します)、再試行のたびにアカウントを再確認し、`Retry-After` は 15 秒まで尊重してそれを超える場合はクライアントに渡します。ゲートウェイの判定(クォータ、残高、CAPTCHA、モデル、認証)、リクエストのエラー、出力開始後の障害は再試行しません。強制されたクライアントセッション用の順序付きトランスポートでは、リクエストを書き終えた後の障害も再試行しません。`message_stop` なしで終わったネイティブの Anthropic ストリームには、最後に `api_error` 型の `event: error` フレーム 1 つを付加します(メッセージは `upstream_incomplete`、読み取り失敗時は `upstream_stream_error` を示します)。未完成の最後のフレームは破棄するので、クライアントは無言の途切れではなく解析できる障害を受け取ります。 +出力前の一時的な障害(接続拒否やリセット、HTTP 500/502/503/504/524/529、429、公式クライアントが再試行するゲートウェイのエラーコード)は、同じアカウントで待ち時間を増やしながら最大 3 回再試行します。予算は公式クライアントより小さく(基本待ち時間 1 秒で倍増、`ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` で設定:基本待ち時間をミリ秒で指定、既定 500、上限 10000。`off` にすると接続が確立しなかった場合の再試行だけを残します)、再試行のたびにアカウントを再確認し、`Retry-After` は 15 秒まで尊重してそれを超える場合はクライアントに渡します。ゲートウェイの判定(クォータ、残高、CAPTCHA、モデル、認証)、リクエストのエラー、出力開始後の障害は再試行しません。最初のコンテンツイベントの前に失敗したストリーム(`overloaded_error` などのエラーイベントや切断)も同じように再試行します。その場合、クライアントは最初のコンテンツイベントが届いた時点で(最大 15 秒後に)レスポンスヘッダーを受け取ります。start-plan では、レスポンスや切断の後の再試行ごとに新しい CAPTCHA トークンを取得します。強制されたクライアントセッション用の順序付きトランスポートは、レスポンス前に切れた接続を fetch 経路と同じように再試行します(同じアカウント。クォータのフェイルオーバーが別のアカウントで再送することはありません)。`message_stop` なしで終わったネイティブの Anthropic ストリームには、最後に `api_error` 型の `event: error` フレーム 1 つを付加します(メッセージは `upstream_incomplete`、読み取り失敗時は `upstream_stream_error` を示します)。未完成の最後のフレームは破棄するので、クライアントは無言の途切れではなく解析できる障害を受け取ります。 ## セキュリティ diff --git a/zcode-proxy-src/README.md b/zcode-proxy-src/README.md index 2f60f4d..daa164e 100644 --- a/zcode-proxy-src/README.md +++ b/zcode-proxy-src/README.md @@ -50,7 +50,7 @@ Do not use those switches. The solver, its security gates, and the callable The local repair decodes gzip, deflate, and Brotli before interpreting translated streams or JSON error envelopes. Empty or undecodable batch bodies are reported as `upstream_invalid_response`, not successful empty answers. The proxy does not substitute its daemon directory for the calling harness workspace; `ZCODE_IDENTITY_ENV_CWD` remains an explicit override. -Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429, and the gateway error codes the official client retries) are retried up to three times on the same account with a growing delay, a smaller budget than the official client's (base delay 1 s doubling, set by `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: base delay in milliseconds, default 500, maximum 10000; `off` keeps only the retry of never-established connections); the account is re-checked before every retry, a `Retry-After` is honoured up to 15 seconds and passed to the client above that, and gateway verdicts (quota, balance, captcha, model, authentication), request errors and anything after output has started are never retried. On the ordered transport used for enforced client sessions, a failure after the request was fully written is not retried either. A native Anthropic stream that ends without `message_stop` receives one terminal `event: error` frame of type `api_error` (its message names `upstream_incomplete`, or `upstream_stream_error` when the read failed); an unfinished last frame is dropped so clients see a parseable failure instead of a silent truncation. +Transient failures before any output (connection refused or reset, HTTP 500/502/503/504/524/529, 429, and the gateway error codes the official client retries) are retried up to three times on the same account with a growing delay, a smaller budget than the official client's (base delay 1 s doubling, set by `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS`: base delay in milliseconds, default 500, maximum 10000; `off` keeps only the retry of never-established connections); the account is re-checked before every retry, a `Retry-After` is honoured up to 15 seconds and passed to the client above that, and gateway verdicts (quota, balance, captcha, model, authentication), request errors and anything after output has started are never retried. A stream that fails before its first content event (an error event such as `overloaded_error`, or a cut connection) is retried the same way; the client then receives the response headers once the first content event arrived (at most 15 seconds later). On start-plan, every retry after a response or a dropped connection takes a fresh captcha token. The ordered transport used for enforced client sessions retries a connection dropped before the response like the fetch path (same account; the quota failover never replays it on another account). A native Anthropic stream that ends without `message_stop` receives one terminal `event: error` frame of type `api_error` (its message names `upstream_incomplete`, or `upstream_stream_error` when the read failed); an unfinished last frame is dropped so clients see a parseable failure instead of a silent truncation. ## Security diff --git a/zcode-proxy-src/README.zh-CN.md b/zcode-proxy-src/README.zh-CN.md index d049273..64a799a 100644 --- a/zcode-proxy-src/README.zh-CN.md +++ b/zcode-proxy-src/README.zh-CN.md @@ -43,7 +43,7 @@ node /zcode-proxy-src/captcha-compatibility.mjs [re 本地修复在解析转换流或 JSON 错误响应前解码 gzip、deflate 和 Brotli。空的或无法解码的批量响应会报告为 `upstream_invalid_response`,不会视为成功的空回复。代理不会将自身进程的目录冒充为调用方工作目录;`ZCODE_IDENTITY_ENV_CWD` 仍可用于显式覆盖。 -输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、429,以及官方客户端会重试的网关错误码)会在同一账号上以递增等待最多重试三次,预算比官方客户端小(基础等待 1 秒并翻倍,由 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 设置:基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试);每次重试前都会重新检查账号,`Retry-After` 最多遵守 15 秒,超过则交给客户端;网关裁定(配额、余额、验证码、模型、认证)、请求错误以及输出开始后的任何故障都不会重试。在用于强制客户端会话的有序传输上,请求完整写出之后的故障同样不会重试。没有 `message_stop` 就结束的原生 Anthropic 流会收到一个类型为 `api_error` 的终止 `event: error` 帧(消息注明 `upstream_incomplete`,读取失败时为 `upstream_stream_error`);未完成的最后一帧会被丢弃,让客户端看到可解析的故障而不是无声截断。 +输出开始前的暂时性故障(连接被拒绝或重置、HTTP 500/502/503/504/524/529、429,以及官方客户端会重试的网关错误码)会在同一账号上以递增等待最多重试三次,预算比官方客户端小(基础等待 1 秒并翻倍,由 `ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS` 设置:基础等待毫秒数,默认 500,上限 10000;`off` 只保留对从未建立的连接的重试);每次重试前都会重新检查账号,`Retry-After` 最多遵守 15 秒,超过则交给客户端;网关裁定(配额、余额、验证码、模型、认证)、请求错误以及输出开始后的任何故障都不会重试。在第一个内容事件之前失败的流(如 `overloaded_error` 这类错误事件或连接被切断)也会以同样方式重试;此时客户端会在第一个内容事件到达时(最多 15 秒后)收到响应头。在 start-plan 下,每次在响应或连接断开之后的重试都会使用新的验证码令牌。用于强制客户端会话的有序传输会像 fetch 路径一样重试在响应之前断开的连接(同一账号;配额故障转移绝不会在其他账号上重放)。没有 `message_stop` 就结束的原生 Anthropic 流会收到一个类型为 `api_error` 的终止 `event: error` 帧(消息注明 `upstream_incomplete`,读取失败时为 `upstream_stream_error`);未完成的最后一帧会被丢弃,让客户端看到可解析的故障而不是无声截断。 ## 安全 diff --git a/zcode-proxy-src/src/auth/account-pool.test.ts b/zcode-proxy-src/src/auth/account-pool.test.ts index ef07afd..7cb6a1b 100644 --- a/zcode-proxy-src/src/auth/account-pool.test.ts +++ b/zcode-proxy-src/src/auth/account-pool.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { AuthManager } from "./manager.js"; import { createAccountRotator } from "./account-rotator.js"; +import { AccountStoreError } from "./account-store.js"; import { resolveClaimJwt } from "../claim/runtime.js"; const first = { id: "one", credential: { apiKey: "pool-one", provider: "zai" as const } }; @@ -65,6 +66,119 @@ describe("AuthManager account pool", () => { expect(snapshots[1]).toContain("two:61000"); }); + it("keeps a failed metadata write unsaved and rewrites it in the background until it lands (lock contention)", async () => { + const rotator = createAccountRotator([first, second], { now: () => 1_000 }); + let failuresLeft = 2; + const written: string[] = []; + const auth = new AuthManager({ + accountRotator: rotator, + persistenceRetryDelaysMs: [1, 1, 1], + persistAccounts: async (profiles) => { + if (failuresLeft > 0) { + failuresLeft -= 1; + throw new AccountStoreError("locked", "store locked by another process"); + } + written.push(profiles.map((p) => `${p.id}:${p.exhaustedUntil ?? 0}`).join(",")); + }, + }); + auth.markCredentialExhausted(first.credential, "1005"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const failed = auth.getPersistenceStatus(); + expect(failed.state).toBe("error"); + expect(failed.unsaved).toBe(true); + expect(failed.nextRetryAt).toBeDefined(); + await auth.settlePersistence(); + expect(written.length).toBe(1); + expect(written[0]).toContain("one:61000"); // the quarantine reached the store + const clean = auth.getPersistenceStatus(); + expect(clean.state).toBe("clean"); + expect(clean.unsaved).toBe(false); + expect(clean.attempts).toBe(0); + }); + + it("stops after the bounded schedule and never retries a corrupt store; the next change starts over", async () => { + const rotator = createAccountRotator([first, second], { now: () => 1_000 }); + let calls = 0; + const auth = new AuthManager({ + accountRotator: rotator, + persistenceRetryDelaysMs: [1, 1], + persistAccounts: async () => { calls += 1; throw new AccountStoreError("locked", "busy"); }, + }); + auth.markCredentialExhausted(first.credential, "1005"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await auth.settlePersistence(); + expect(calls).toBe(3); // inline write + 2 scheduled retries + const spent = auth.getPersistenceStatus(); + expect(spent.unsaved).toBe(true); + expect(spent.nextRetryAt).toBeUndefined(); + expect(spent.attempts).toBe(3); + + let corruptCalls = 0; + const corrupt = new AuthManager({ + accountRotator: createAccountRotator([first, second], { now: () => 1_000 }), + persistenceRetryDelaysMs: [1, 1], + persistAccounts: async () => { corruptCalls += 1; throw new AccountStoreError("corrupt", "cannot decrypt"); }, + }); + corrupt.markCredentialExhausted(first.credential, "1005"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await corrupt.settlePersistence(); + expect(corruptCalls).toBe(1); + expect(corrupt.getPersistenceStatus().unsaved).toBe(true); + expect(corrupt.getPersistenceStatus().nextRetryAt).toBeUndefined(); + }); + + it("a new change after a spent schedule starts the full schedule again; flushPersistence writes at once (shutdown)", async () => { + const rotator = createAccountRotator([first, second], { now: () => 1_000 }); + let failing = true; + let calls = 0; + const written: string[] = []; + const auth = new AuthManager({ + accountRotator: rotator, + persistenceRetryDelaysMs: [1], + persistAccounts: async (profiles) => { + calls += 1; + if (failing) throw new AccountStoreError("locked", "busy"); + written.push(profiles.map((p) => `${p.id}:${p.exhaustedUntil ?? 0}`).join(",")); + }, + }); + auth.markCredentialExhausted(first.credential, "1005"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await auth.settlePersistence(); + expect(calls).toBe(2); // inline + the single scheduled retry + auth.markCredentialExhausted(second.credential, "1113"); // new change: fresh schedule + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(auth.getPersistenceStatus().nextRetryAt).toBeDefined(); + failing = false; + await auth.flushPersistence(); + expect(written.length).toBe(1); + expect(written[0]).toContain("two:61000"); + expect(auth.getPersistenceStatus().unsaved).toBe(false); + await auth.settlePersistence(); + }); + + it("a new change supersedes a scheduled retry and writes the newest snapshot inline", async () => { + const rotator = createAccountRotator([first, second], { now: () => 1_000 }); + let failFirst = true; + const written: string[] = []; + const auth = new AuthManager({ + accountRotator: rotator, + persistenceRetryDelaysMs: [60_000], + persistAccounts: async (profiles) => { + if (failFirst) { failFirst = false; throw new Error("EBUSY"); } + written.push(profiles.map((p) => `${p.id}:${p.exhaustedUntil ?? 0}`).join(",")); + }, + }); + auth.markCredentialExhausted(first.credential, "1005"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(auth.getPersistenceStatus().nextRetryAt).toBeDefined(); + auth.markCredentialExhausted(second.credential, "1113"); + await auth.settlePersistence(); // returns without waiting 60 s: the retry was cancelled + expect(written.length).toBe(1); + expect(written[0]).toContain("one:61000"); + expect(written[0]).toContain("two:61000"); + expect(auth.getPersistenceStatus().unsaved).toBe(false); + }); + it("does not fall back to the legacy credential store for pool claims", async () => { const pooled = new AuthManager({ accountRotator: createAccountRotator([ { id: "no-jwt", credential: { provider: "zai", apiKey: "pool-key" } }, diff --git a/zcode-proxy-src/src/auth/manager.ts b/zcode-proxy-src/src/auth/manager.ts index 00076fd..23c4514 100644 --- a/zcode-proxy-src/src/auth/manager.ts +++ b/zcode-proxy-src/src/auth/manager.ts @@ -1,12 +1,35 @@ import { createHash } from "node:crypto"; import { credentialString, isExpired, type Credential } from "./types.js"; -import type { AccountProfile } from "./account-store.js"; +import { AccountStoreError, type AccountProfile } from "./account-store.js"; import { AccountRotator, NoUsableAccountError, type AccountHandle, type AccountOperation } from "./account-rotator.js"; +/** + * Wait schedule for a failed account-metadata write (store lock held by + * another process, transient I/O): the change stays marked unsaved and is + * rewritten on this schedule in the background, without holding any request. + */ +export const DEFAULT_PERSISTENCE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000, 4_000, 15_000]; + +/** Redacted persistence state for /health, `accounts health` and doctor. */ +export interface PersistenceStatus { + state: "clean" | "dirty" | "error"; + code?: string; + /** Time of the last failure (ms since epoch). */ + at?: number; + /** Failed writes since the last successful one. */ + attempts: number; + /** True while a change is not on disk yet (write pending, retrying, or given up). */ + unsaved: boolean; + /** Next scheduled background retry, when one is pending. */ + nextRetryAt?: number; +} + export interface CredentialSource { plan?: string; /** Clock seam for bounded transient persistence retry; defaults to Date.now. */ now?: () => number; + /** Background retry schedule after a failed metadata write (default DEFAULT_PERSISTENCE_RETRY_DELAYS_MS). */ + persistenceRetryDelaysMs?: readonly number[]; /** Opaque revision of the existing desktop source; never an upstream call. */ importRevision?: () => string; persistCredential?: (credential: Credential) => Promise; @@ -23,6 +46,12 @@ export interface CredentialSource { persistAccounts?: (accounts: readonly AccountProfile[]) => Promise; } +/** A locked store, a lost race or an I/O error is worth a background retry; a corrupt or invalid store is not. */ +function isRetryablePersistenceError(err: unknown): boolean { + if (err instanceof AccountStoreError) return err.code === "locked" || err.code === "persistence" || err.code === "conflict"; + return true; +} + function valid(cred: Credential | null, allowExpired = false): cred is Credential { return !!cred && typeof cred.apiKey === "string" && cred.apiKey.trim().length > 0 && (cred.provider === "zai" || cred.provider === "bigmodel") @@ -48,8 +77,11 @@ export class AuthManager { private poolRecoveries = new Map>(); private poolPersistence: Promise | undefined; private poolPersistenceDirty = false; + private persistenceRetryTimer: ReturnType | undefined; + private persistenceRetryIndex = 0; + private persistenceSettleWaiters: Array<() => void> = []; private readonly sameCredentialRetryBlockedUntil = new Map(); - private persistenceStatus: { state: "clean" | "dirty" | "error"; code?: string; at?: number; attempts: number } = { state: "clean", attempts: 0 }; + private persistenceStatus: PersistenceStatus = { state: "clean", attempts: 0, unsaved: false }; constructor(private source: CredentialSource = {}) {} /** True when an explicitly enabled account pool is authoritative. */ @@ -152,10 +184,38 @@ export class AuthManager { } /** Redacted machine-readable persistence state for doctor/live status. */ - getPersistenceStatus(): Readonly<{ state: "clean" | "dirty" | "error"; code?: string; at?: number; attempts: number }> { + getPersistenceStatus(): Readonly { return { ...this.persistenceStatus }; } + /** + * Shutdown: write unsaved account metadata now instead of on the background + * schedule. Resolves when that one attempt ended (success or failure); the + * caller bounds it with its own deadline and may inspect getPersistenceStatus(). + */ + async flushPersistence(): Promise { + if (!this.persistenceStatus.unsaved) { + if (this.poolPersistence) await this.poolPersistence; + return; + } + await this.persistAccounts(); + } + + /** Await the in-flight metadata write and every scheduled background retry (tests). */ + async settlePersistence(): Promise { + for (;;) { + if (this.poolPersistence) { + await this.poolPersistence; + continue; + } + if (this.persistenceRetryTimer) { + await new Promise((resolve) => this.persistenceSettleWaiters.push(resolve)); + continue; + } + return; + } + } + /** Re-read the authoritative store and validate an admitted request handle. */ async validateAccountHandle(handle: AccountHandle): Promise { if (!this.source.accountRotator) return true; @@ -273,7 +333,11 @@ export class AuthManager { const rotator = this.source.accountRotator; if (!persist || !rotator) return Promise.resolve(); this.poolPersistenceDirty = true; - this.persistenceStatus = { state: "dirty", attempts: this.persistenceStatus.attempts }; + this.persistenceStatus = { ...this.persistenceStatus, state: "dirty", unsaved: true, nextRetryAt: undefined }; + // A fresh inline pass supersedes a scheduled background retry: it writes + // the newest snapshot, and a failure re-arms the full schedule from there. + this.cancelPersistenceRetry(); + this.persistenceRetryIndex = 0; if (this.poolPersistence) return this.poolPersistence; // One writer owns the store at a time. A failure/health change arriving // while encryption or I/O is pending sets dirty again; the next pass uses @@ -281,28 +345,67 @@ export class AuthManager { // Queue the first pass so the shared promise is installed even when an // injected writer throws synchronously. Clear it in the loop's own final // microtask so no completed promise can absorb a later dirty update. - this.poolPersistence = Promise.resolve().then(async () => { - try { - let passes = 0; - do { - passes += 1; - this.poolPersistenceDirty = false; - try { - await persist(rotator.profiles()); - this.persistenceStatus = { state: "clean", attempts: this.persistenceStatus.attempts }; - } catch { - this.persistenceStatus = { state: "error", code: "ACCOUNT_STORE_PERSISTENCE_FAILED", at: Date.now(), attempts: this.persistenceStatus.attempts + 1 }; - // Do not spin forever while a lock or disk is unavailable. The - // live state remains usable only after the next authoritative - // refresh succeeds; diagnostics expose this failure explicitly. - this.poolPersistenceDirty = false; - } - } while (this.poolPersistenceDirty && passes < 3); - } finally { this.poolPersistence = undefined; } - }); + this.poolPersistence = Promise.resolve().then(() => this.runPersistencePasses(persist, rotator)); return this.poolPersistence; } + private async runPersistencePasses(persist: NonNullable, rotator: AccountRotator): Promise { + try { + let passes = 0; + do { + passes += 1; + this.poolPersistenceDirty = false; + try { + await persist(rotator.profiles()); + this.persistenceRetryIndex = 0; + this.persistenceStatus = { state: "clean", attempts: 0, unsaved: this.poolPersistenceDirty }; + } catch (err) { + const now = this.source.now?.() ?? Date.now(); + this.persistenceStatus = { + state: "error", code: "ACCOUNT_STORE_PERSISTENCE_FAILED", at: now, + attempts: this.persistenceStatus.attempts + 1, unsaved: true, + }; + // The change is kept, never dropped: a store lock held by another + // process or a transient I/O error is rewritten on a bounded + // background schedule (no request waits for it); a store that cannot + // be used at all (corrupt, invalid) is left to the operator, and the + // next metadata change starts a fresh inline write anyway. + if (isRetryablePersistenceError(err)) this.schedulePersistenceRetry(persist, rotator); + return; + } + } while (this.poolPersistenceDirty && passes < 3); + if (this.poolPersistenceDirty) { + // Changes kept arriving during three passes: continue in the + // background instead of leaving the newest snapshot unwritten. + this.persistenceStatus = { ...this.persistenceStatus, state: "dirty", unsaved: true }; + this.schedulePersistenceRetry(persist, rotator); + } + } finally { this.poolPersistence = undefined; } + } + + private schedulePersistenceRetry(persist: NonNullable, rotator: AccountRotator): void { + const delays = this.source.persistenceRetryDelaysMs ?? DEFAULT_PERSISTENCE_RETRY_DELAYS_MS; + if (this.persistenceRetryIndex >= delays.length) return; // schedule spent: the next change starts over + const delay = delays[this.persistenceRetryIndex++]; + const now = this.source.now?.() ?? Date.now(); + this.persistenceStatus = { ...this.persistenceStatus, nextRetryAt: now + delay }; + this.persistenceRetryTimer = setTimeout(() => { + this.persistenceRetryTimer = undefined; + this.poolPersistenceDirty = true; + this.persistenceStatus = { ...this.persistenceStatus, nextRetryAt: undefined }; + const pass = this.poolPersistence ?? (this.poolPersistence = Promise.resolve().then(() => this.runPersistencePasses(persist, rotator))); + void pass.finally(() => { for (const resolve of this.persistenceSettleWaiters.splice(0)) resolve(); }); + }, delay); + this.persistenceRetryTimer.unref?.(); + } + + private cancelPersistenceRetry(): void { + if (!this.persistenceRetryTimer) return; + clearTimeout(this.persistenceRetryTimer); + this.persistenceRetryTimer = undefined; + for (const resolve of this.persistenceSettleWaiters.splice(0)) resolve(); + } + /** * Whether one same-account resend may still use this credential or handle. * Pool mode re-checks full admission via the rotator (revision, identity, diff --git a/zcode-proxy-src/src/index.ts b/zcode-proxy-src/src/index.ts index 707771a..5bb9878 100644 --- a/zcode-proxy-src/src/index.ts +++ b/zcode-proxy-src/src/index.ts @@ -250,7 +250,7 @@ async function serve(configPath: string | undefined, debug: boolean): Promise process.exit(0), 10_000); forceExit.unref(); - void server.close().then(() => process.exit(0)); + void server.close().then(() => flushAccountMetadata(auth)).then(() => process.exit(0)); return true; }, }); @@ -279,15 +279,32 @@ async function serve(configPath: string | undefined, debug: boolean): Promise { + if (stopping) return; + stopping = true; + shutdownCaptchaRuntime(); + void flushAccountMetadata(auth).finally(() => server.stop(true)); + }; process.on("SIGINT", () => { console.log("\nShutting down..."); - shutdownCaptchaRuntime(); - server.stop(true); - }); - process.on("SIGTERM", () => { - shutdownCaptchaRuntime(); - server.stop(true); + stop(); }); + process.on("SIGTERM", stop); +} + +/** Bounded final write of unsaved account metadata (referenced timer: shutdown waits for it). */ +export async function flushAccountMetadata(auth: { flushPersistence?: () => Promise; getPersistenceStatus?: () => { unsaved?: boolean } }, deadlineMs = 3_000): Promise { + if (typeof auth.flushPersistence !== "function") return; + let timer: ReturnType | undefined; + await Promise.race([ + auth.flushPersistence().catch(() => {}), + new Promise((resolve) => { timer = setTimeout(resolve, deadlineMs); }), + ]); + if (timer) clearTimeout(timer); + if (auth.getPersistenceStatus?.().unsaved) console.error("[accounts] shutdown: account metadata could not be written; it will be rebuilt from the next requests"); } /** @@ -864,6 +881,21 @@ async function doctorAccounts(args: string[]): Promise { } catch (err) { checks.push({ code: "doctor_unavailable", status: "error", detail: safeAccountError(err) }); } + // Runtime metadata (cooldowns, last use) the running proxy could not write + // yet: a warning, since the proxy keeps retrying in the background and the + // next change writes again; lost only if the proxy stops before it lands. + try { + const { ok, body } = await fetchLiveAccounts("/accounts/status", 3_000); + const persistence = ok && body && typeof body === "object" ? (body as { persistence?: { unsaved?: unknown; attempts?: unknown; nextRetryAt?: unknown } }).persistence : undefined; + if (persistence?.unsaved === true) { + const next = typeof persistence.nextRetryAt === "number" ? `; next retry ${new Date(persistence.nextRetryAt).toISOString()}` : "; retries paused until the next change"; + checks.push({ code: "runtime_changes_unsaved", status: "warning", detail: `account metadata not written to the store yet (${Number(persistence.attempts) || 0} failed write(s)${next})` }); + } else if (persistence) { + checks.push({ code: "runtime_changes_saved", status: "ok" }); + } + } catch { + // proxy not running: nothing unsaved in memory + } const output = { schemaVersion: 1, source: "offline", asOf: new Date().toISOString(), checks }; if (json) console.log(JSON.stringify(output, null, 2)); else for (const check of checks) console.log(`${check.status.toUpperCase()} ${check.code}${check.detail ? `: ${check.detail}` : ""}`); diff --git a/zcode-proxy-src/src/proxy/gateway-codes.ts b/zcode-proxy-src/src/proxy/gateway-codes.ts new file mode 100644 index 0000000..1393ac6 --- /dev/null +++ b/zcode-proxy-src/src/proxy/gateway-codes.ts @@ -0,0 +1,17 @@ +/** + * Gateway business codes as the official ZCode client (3.14.3, + * failure-provider-business-codes.ts) classifies them. Shared by the + * transient ladder (handler.ts) and the stream prelude gate + * (stream-prelude.ts) so both retry exactly the same verdicts. + */ + +/** Transient gateway conditions the official client retries, with any HTTP status or inside a stream. */ +export const RETRYABLE_GATEWAY_CODES: ReadonlySet = new Set([500, 1120, 1230, 1234, 1302, 1303, 1305, 1312, 2007, 3002]); + +/** + * Verdicts about this request or account: quota/balance (their own retry + * schedule and rotation), captcha (its own re-solve), model, authorization, + * authentication and the thinking-config rejection. Never retried, whatever + * the HTTP status — the official client treats them as terminal too. + */ +export const TERMINAL_GATEWAY_CODES: ReadonlySet = new Set([401, 1005, 1006, 1113, 1210, 3001, 3006, 3007, 3008, 3009, 3010, 3012]); diff --git a/zcode-proxy-src/src/proxy/handler-resilience.test.ts b/zcode-proxy-src/src/proxy/handler-resilience.test.ts index 4d15efb..cd5c0db 100644 --- a/zcode-proxy-src/src/proxy/handler-resilience.test.ts +++ b/zcode-proxy-src/src/proxy/handler-resilience.test.ts @@ -119,19 +119,33 @@ describe("dispatchWithConnectRetry — replay safety (C1-02)", () => { } }); - it("never retries an allowlisted error flagged postWrite", async () => { + it("retries a drop flagged postWrite on the same account (parity with the fetch path); the connect-only policy leaves it alone", async () => { let calls = 0; - const postWrite = Object.assign(new Error("connect refused after write"), { - code: "ECONNREFUSED", + const postWrite = Object.assign(new Error("upstream closed before sending response headers"), { + code: "ECONNRESET", postWrite: true, }); - - await expect(dispatchWithConnectRetry(async () => { + const resp = await dispatchWithConnectRetry(async () => { calls += 1; - throw postWrite; - }, { retryDelayMs: 0 })).rejects.toBe(postWrite); + if (calls === 1) throw postWrite; + return new Response("ok"); + }, { retryDelayMs: 0 }); + expect(resp.status).toBe(200); + expect(calls).toBe(2); - expect(calls).toBe(1); + const previous = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "off"; + try { + calls = 0; + await expect(dispatchWithConnectRetry(async () => { + calls += 1; + throw postWrite; + })).rejects.toBe(postWrite); + expect(calls).toBe(1); + } finally { + if (previous === undefined) delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + else process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = previous; + } }); }); @@ -258,4 +272,99 @@ describe("proxyRequest — start-plan resilience (PR #34 review P1/P3)", () => { const body = await resp.json(); expect(body.choices[0].message.content).toBe("resilience reply"); }); + + function mockCaptcha(): { minted: () => number } { + let seq = 0; + mock.module("./captcha.js", () => ({ + detectCaptchaChallenge: (resp: Response): string | null => resp.headers.get("x-aliyun-captcha-verify-param"), + getCaptchaToken: async () => { seq += 1; return { verifyParam: `fresh-${seq}`, region: "sgp" }; }, + RETRY_HEADERS: { PARAM: "x-aliyun-captcha-verify-param", REGION: "x-aliyun-captcha-verify-region" }, + })); + return { minted: () => seq }; + } + const chatReq = (stream = false): Request => new Request("http://localhost:8080/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "glm-4.6", max_tokens: 16, stream, messages: [{ role: "user", content: "hi" }] }), + }); + const START = 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_p","type":"message","role":"assistant","model":"glm-4.6","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}\n\n'; + const BLOCK = 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'; + const DELTA = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}\n\n'; + const STOP = 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; + const OVERLOADED = 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n'; + const INVALID = 'event: error\ndata: {"type":"error","error":{"type":"invalid_request_error","message":"bad"}}\n\n'; + const sse = (text: string): Response => new Response(text, { status: 200, headers: { "content-type": "text/event-stream" } }); + + it("re-dispatches a stream that fails in its prelude (before any content) on the same account with a fresh captcha token; the client sees only the good stream", async () => { + const captcha = mockCaptcha(); + const seen: Array<{ auth: string | null; token: string | null }> = []; + const fetchMock = mock(async (req: Request): Promise => { + seen.push({ auth: req.headers.get("authorization"), token: req.headers.get("x-aliyun-captcha-verify-param") }); + return seen.length === 1 ? sse(START + OVERLOADED) : sse(START + BLOCK + DELTA + STOP); + }); + const auth = new AuthManager(); + auth.setOAuthCredential({ apiKey: PLAN_KEY, provider: "zai", jwt: PLAN_JWT }); + const previous = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "0"; + try { + const resp = await proxyRequest(chatReq(true), "anthropic", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any }); + expect(resp.status).toBe(200); + const text = await resp.text(); + expect(seen.length).toBe(2); + expect(seen[0].auth).toBe(seen[1].auth); // same account + expect(seen[0].token).toBe("fresh-1"); + expect(seen[1].token).toBe("fresh-2"); // a response-based retry never re-sends a possibly spent token + expect(captcha.minted()).toBe(2); + expect(text).toBe(START + BLOCK + DELTA + STOP); // the failed prelude never reached the client + expect(text).not.toContain("overloaded_error"); + } finally { + if (previous === undefined) delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + else process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = previous; + } + }); + + it("never retries a terminal error in the prelude, and never anything after the first content event", async () => { + mockCaptcha(); + const auth = new AuthManager(); + auth.setOAuthCredential({ apiKey: PLAN_KEY, provider: "zai", jwt: PLAN_JWT }); + let calls = 0; + const terminal = await proxyRequest(chatReq(true), "anthropic", { + config: TEST_CONFIG, auth, fetchImpl: mock(async () => { calls += 1; return sse(START + INVALID); }) as any, + }); + expect(await terminal.text()).toBe(START + INVALID); + expect(calls).toBe(1); + calls = 0; + const late = await proxyRequest(chatReq(true), "anthropic", { + config: TEST_CONFIG, auth, fetchImpl: mock(async () => { calls += 1; return sse(START + BLOCK + OVERLOADED); }) as any, + }); + expect(await late.text()).toBe(START + BLOCK + OVERLOADED); + expect(calls).toBe(1); + }); + + it("keeps the captcha token after a never-connected attempt and mints a fresh one after a drop post-write", async () => { + mockCaptcha(); + const tokens: Array = []; + let calls = 0; + const fetchMock = mock(async (req: Request): Promise => { + calls += 1; + tokens.push(req.headers.get("x-aliyun-captcha-verify-param")); + if (calls === 1) throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + if (calls === 2) throw Object.assign(new Error("reset after write"), { code: "ECONNRESET" }); + return new Response(ANTHROPIC_OK, { status: 200, headers: { "content-type": "application/json" } }); + }); + const auth = new AuthManager(); + auth.setOAuthCredential({ apiKey: PLAN_KEY, provider: "zai", jwt: PLAN_JWT }); + const previous = process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = "0"; + try { + const resp = await proxyRequest(chatReq(), "anthropic", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any }); + expect(resp.status).toBe(200); + expect(calls).toBe(3); + expect(tokens[1]).toBe(tokens[0]); // never connected: the token was not seen by the gateway + expect(tokens[2]).not.toBe(tokens[1]); // a drop may have spent it + } finally { + if (previous === undefined) delete process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS; + else process.env.ZCODE_PROXY_TRANSIENT_RETRY_UNIT_MS = previous; + } + }); }); diff --git a/zcode-proxy-src/src/proxy/handler.ts b/zcode-proxy-src/src/proxy/handler.ts index d2ef7c6..6975bb4 100644 --- a/zcode-proxy-src/src/proxy/handler.ts +++ b/zcode-proxy-src/src/proxy/handler.ts @@ -48,6 +48,9 @@ import { anthropicSseToOpenaiSse, openaiSseToAnthropicSse } from "../translator/ import type { OpenAIChatRequest, OpenAIChatResponse, AnthropicMessagesRequest, AnthropicMessagesResponse } from "../translator/types.js"; import { dumpPhase, dumpHeaders, dumpBody, dumpEnabled, SENSITIVE_HEADERS } from "./dump.js"; import { ensureAnthropicSseTerminal } from "./sse-terminal.js"; +import { gateStreamPrelude, isGatedStream, type PreludeVerdict } from "./stream-prelude.js"; +import { RETRYABLE_GATEWAY_CODES, TERMINAL_GATEWAY_CODES } from "./gateway-codes.js"; +export { RETRYABLE_GATEWAY_CODES, TERMINAL_GATEWAY_CODES } from "./gateway-codes.js"; import { inflateWithCap, decodeContentStream } from "./inflate.js"; import { buildAnthropicMetadataUserId } from "./trace-headers.js"; @@ -281,21 +284,32 @@ export async function proxyRequest( // attempt re-dispatches a FRESH Request — a reused Request has its body // stream marked used after the first fetch (start-plan hits the plain // pass-through path where dispatch does NOT rebuild the Request). On - // start-plan the retries carry the same pre-minted captcha token; if the - // gateway had already consumed it, the captcha layer below re-solves once. - let dispatchAttempt = 0; + // start-plan a retry after a response, a drop after the write or a failed + // stream prelude takes a fresh pooled captcha token (the gateway may have + // spent the first one); a never-connected attempt keeps it, and when no + // fresh token can be minted the previous one is re-sent and the captcha + // layer below re-solves on a challenge. upstreamResp = await dispatchWithConnectRetry( - () => { - dispatchAttempt += 1; - const currentReq = dispatchAttempt === 1 - ? upstreamReq - : buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); - return dispatch(currentReq, upstreamHeaderPairs); + async ({ attempt, previous }) => { + if (attempt === 1) return dispatch(upstreamReq, upstreamHeaderPairs); + if (startPlan && previous?.kind !== "connect") { + try { + const captchaModule = opts.captcha ?? await loadCaptcha(); + const token = await captchaModule.getCaptchaToken(config.identity.appVersion); + captchaHeaders = { [captchaModule.RETRY_HEADERS.PARAM]: token.verifyParam, [captchaModule.RETRY_HEADERS.REGION]: token.region }; + upstreamHeaderPairs = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, config.identity, config.plan, captchaHeaders, clientSession); + } catch { + // keep the previous token + } + } + return dispatch(buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession), upstreamHeaderPairs); }, { isAborted: () => clientReq.signal.aborted, signal: clientReq.signal, beforeRetry: () => accountHandleStillCurrent(auth, accountHandle), + // Translation mode reads a body the transport already inflated. + streamPrelude: (resp) => gateStreamPrelude(resp, { decoded: translateMode, signal: clientReq.signal }), onRetry: (attempt, reason, delayMs) => { if (debug) debugError(reqId, "upstream_transient_retry", `attempt ${attempt}/${MAX_TRANSIENT_ATTEMPTS} failed (${reason}), retrying in ${delayMs}ms`); console.log(`${reqId} upstream transient failure (${reason}), retry ${attempt + 1}/${MAX_TRANSIENT_ATTEMPTS} in ${delayMs}ms`); @@ -544,25 +558,13 @@ const DROP_ERROR_CODES = new Set([ "ECONNRESET", "EPIPE", "ETIMEDOUT", "ECONNABORTED", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "ConnectionClosed", ]); -const DROP_ERROR_MESSAGES = [/socket hang up/i, /other side closed/i, /connection closed/i, /connection reset/i]; +const DROP_ERROR_MESSAGES = [/socket hang up/i, /other side closed/i, /connection closed/i, /connection reset/i, /closed before sending response headers/i]; /** TLS/certificate failures are configuration problems, never transient. */ const NON_TRANSIENT_CODE = /^(ERR_TLS_|ERR_SSL|CERT_|UNABLE_TO_|DEPTH_ZERO_|SELF_SIGNED)/; /** Gateway/edge statuses the official client retries as server errors. */ const TRANSIENT_STATUSES = new Set([500, 502, 503, 504, 524, 529]); -/** - * Gateway business codes the official client retries (its whitelist, from - * failure-provider-business-codes.ts of zcode 3.14.3): transient gateway - * conditions that arrive as a JSON envelope, with any HTTP status. - */ -export const RETRYABLE_GATEWAY_CODES = new Set([500, 1120, 1230, 1234, 1302, 1303, 1305, 1312, 2007, 3002]); -/** - * Verdicts about this request or account: quota/balance (their own retry - * schedule and rotation), captcha (its own re-solve), model, authorization, - * authentication and the thinking-config rejection. Never retried by the - * ladder, whatever the HTTP status — the official client treats them as - * terminal too. - */ -export const TERMINAL_GATEWAY_CODES = new Set([401, 1005, 1006, 1113, 1210, 3001, 3006, 3007, 3008, 3009, 3010, 3012]); +// Gateway business codes (retryable vs terminal) live in gateway-codes.ts, +// shared with the stream prelude gate. type TransientKind = "connect" | "drop" | "status"; @@ -577,16 +579,22 @@ function errorChain(err: unknown): Array<{ code?: unknown; name?: unknown; messa return chain; } -/** Classify a thrown dispatch error; null = not retried (unknown, postWrite, abort, TLS). */ +/** + * Classify a thrown dispatch error; null = not retried (unknown, abort, TLS). + * A failure flagged `postWrite` (the ordered transport wrote the full request + * before it failed) is a drop like a reset on the fetch path: no response + * reached the client, and the official client re-sends exactly this class — + * so it is never a "connect" kind (the connect-only policy leaves it alone). + */ export function transientErrorKind(err: unknown): { kind: TransientKind; reason: string } | null { - if (isPostWriteError(err)) return null; const chain = errorChain(err); if (chain.some((e) => e.name === "AbortError" || e.code === "ABORT_ERR")) return null; // A TLS/certificate code anywhere in the chain wins over a socket code that wraps it. if (chain.some((e) => typeof e.code === "string" && NON_TRANSIENT_CODE.test(e.code))) return null; + const postWrite = isPostWriteError(err); for (const e of chain) { if (typeof e.code !== "string") continue; - if (CONNECT_ERROR_CODES.has(e.code)) return { kind: "connect", reason: e.code }; + if (CONNECT_ERROR_CODES.has(e.code)) return { kind: postWrite ? "drop" : "connect", reason: e.code }; if (DROP_ERROR_CODES.has(e.code)) return { kind: "drop", reason: e.code }; } const text = chain.map((e) => (typeof e.message === "string" ? e.message : "")).join(" | "); @@ -665,30 +673,46 @@ async function abortableWait(ms: number, signal?: AbortSignal): Promise { * (RETRYABLE_GATEWAY_CODES) with any status; a Retry-After (seconds or * HTTP-date) is honoured up to TRANSIENT_RETRY_AFTER_CAP_MS, above it * the response is surfaced. + * - a 200 event stream whose prelude fails — an `event: error` of a + * transient kind, or an end/read failure before the first content event + * (see stream-prelude.ts) — when `opts.streamPrelude` is given; the + * official client retries exactly this window. * Never retried: terminal gateway verdicts (quota, balance, auth, model, * captcha — their own layers handle them), captcha challenges, request * validation errors, TLS/certificate failures, anything after the client - * aborted, and errors flagged `postWrite`. + * aborted, and anything after a content event reached the stream. * * Contract (review P1/P2, PR #34/#35; transient extension after the * "Continue fixes it" report): * - `attemptDispatch` must dispatch a FRESH request each call — a reused - * Request has its body stream marked used after the first fetch. + * Request has its body stream marked used after the first fetch; it + * receives the attempt number and why the previous attempt is retried. * - failures flagged `postWrite` (ordered transport already wrote the full - * request) are never retried — the upstream may have processed it. + * request) are drops: re-sent on the SAME account like a reset on the + * fetch path, never replayed on another account (the failover layer + * keeps refusing them). * - no retry once the client aborted (`opts.isAborted` / `opts.signal`). * - `opts.beforeRetry` is consulted after every wait: false ends the ladder * with the last outcome (response returned intact, error rethrown). * - the ladder never touches the sticky account, the quota-retry memo or * the captcha layer: those run on the response it returns. */ +export interface DispatchAttempt { + /** 1-based attempt number. */ + attempt: number; + /** Why the previous attempt is being retried (absent on the first attempt). */ + previous?: { kind: TransientKind | "stream"; reason: string }; +} + export async function dispatchWithConnectRetry( - attemptDispatch: () => Promise, + attemptDispatch: (context: DispatchAttempt) => Promise, opts: { isAborted?: () => boolean; signal?: AbortSignal; /** Re-check before every retry (account still current); false ends the ladder with the last outcome. */ beforeRetry?: () => boolean | Promise; + /** Prelude gate for 200 event streams (stream-prelude.ts); absent = streams are handed on unread. */ + streamPrelude?: (resp: Response) => Promise; onRetry?: (attempt: number, reason: string, delayMs: number) => void; /** Test seam: base unit of the backoff (default 500 ms; 0 disables waiting). */ retryDelayMs?: number; @@ -697,11 +721,12 @@ export async function dispatchWithConnectRetry( const { unitMs, extended } = transientRetryPolicy(opts.retryDelayMs); const aborted = (): boolean => opts.isAborted?.() === true || opts.signal?.aborted === true; const admitted = async (): Promise => (opts.beforeRetry ? (await opts.beforeRetry()) === true : true); + let previous: DispatchAttempt["previous"]; for (let attempt = 1; ; attempt++) { if (aborted()) throw new Error("client aborted before upstream connect"); let resp: Response; try { - resp = await attemptDispatch(); + resp = await attemptDispatch({ attempt, ...(previous ? { previous } : {}) }); } catch (err) { const transient = transientErrorKind(err); if (!transient || (!extended && transient.kind !== "connect") || attempt >= MAX_TRANSIENT_ATTEMPTS || aborted()) throw err; @@ -709,13 +734,27 @@ export async function dispatchWithConnectRetry( opts.onRetry?.(attempt, transient.reason, delayMs); await abortableWait(delayMs, opts.signal); if (aborted() || !(await admitted())) throw err; + previous = { kind: transient.kind, reason: transient.reason }; continue; } if (!extended || attempt >= MAX_TRANSIENT_ATTEMPTS || aborted()) return resp; - const reason = await transientResponseReason(resp); - if (reason === null) return resp; - const retryAfter = retryAfterMs(resp); - if (retryAfter !== null && retryAfter > TRANSIENT_RETRY_AFTER_CAP_MS) return resp; + let reason = await transientResponseReason(resp); + let kind: TransientKind | "stream" = "status"; + let retryAfter: number | null = null; + if (reason !== null) { + retryAfter = retryAfterMs(resp); + if (retryAfter !== null && retryAfter > TRANSIENT_RETRY_AFTER_CAP_MS) return resp; + } else { + // A 200 stream may still fail before any output: read its prelude and + // treat an early transient error or end like a failed request. The + // verdict's response carries the held prelude and the untouched rest. + if (!opts.streamPrelude || !isGatedStream(resp)) return resp; + const verdict = await opts.streamPrelude(resp); + resp = verdict.response; + if (!verdict.retryable) return resp; + reason = `stream ${verdict.kind}: ${verdict.reason}`; + kind = "stream"; + } // Unit 0 means "retry without waiting" (tests, operators): it also skips a // short Retry-After; the cap above still surfaces a long one. const delayMs = unitMs === 0 ? 0 : retryAfter !== null ? retryAfter : transientDelayMs("status", attempt, unitMs); @@ -729,6 +768,7 @@ export async function dispatchWithConnectRetry( } if (!(await admitted())) return resp; void resp.body?.cancel().catch(() => {}); + previous = { kind, reason }; } } diff --git a/zcode-proxy-src/src/proxy/ordered-transport.test.ts b/zcode-proxy-src/src/proxy/ordered-transport.test.ts index d226302..0596b69 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.test.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.test.ts @@ -137,10 +137,11 @@ describe("sendOrderedUpstreamRequest — abort propagation", () => { } }); - it("flags an upstream close before response headers as postWrite (never replayed by any layer)", async () => { + it("flags an upstream close before response headers as postWrite (the failover layer never replays it on another account)", async () => { // The request head and body are on the wire before `end` can arrive, so - // the failure must carry the postWrite mark the retry and failover layers - // refuse — the upstream may have processed the request. + // the failure must carry the postWrite mark: the quota failover refuses to + // replay it on another account (the upstream may have processed the + // request); the transient ladder may re-send it on the same account. const { createServer: createTcpServer } = await import("node:net"); let requests = 0; const tcp = createTcpServer((socket) => { socket.once("data", () => { requests += 1; socket.end(); }); }); diff --git a/zcode-proxy-src/src/proxy/ordered-transport.ts b/zcode-proxy-src/src/proxy/ordered-transport.ts index fb54524..b49d5f5 100644 --- a/zcode-proxy-src/src/proxy/ordered-transport.ts +++ b/zcode-proxy-src/src/proxy/ordered-transport.ts @@ -46,9 +46,11 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P function fail(err: unknown): void { if (!responseStarted && postWrite) { // Review follow-up #2 (PR #34): the full request (head + body) was - // already written to the wire, so the upstream may have processed it - // — resending could duplicate the LLM call and consume quota twice. - // Flag it so the connect-retry loop in handler.ts skips this error. + // already written to the wire, so the upstream may have processed it. + // The flag keeps the quota failover from replaying it on ANOTHER + // account; the transient ladder may re-send it on the same account, + // exactly like a reset on the fetch path (the official client's + // network-failure class). No response byte reached the client. try { (err as { postWrite?: boolean }).postWrite = true; } catch {} } if (responseStarted) { @@ -70,14 +72,13 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P // long-TTFB reasoning request. `fail()` both rejects this promise (a bare // destroy() emits "close", not "error"/"end", and would leave it pending // forever) and errors the consumer-side body stream when the response has - // already started. The resulting error carries `postWrite` (the request - // is fully on the wire by then), so handler's connect-retry ladder skips - // it — combined with the `clientReq.signal.aborted` pre-check there, - // client aborts never enter the retry loop. + // already started. The error is an AbortError, which the transient + // ladder never retries — together with the `clientReq.signal.aborted` + // checks there, client aborts never enter the retry loop. if (req.signal) { const signal = req.signal; const onAbort = (): void => { - fail(new Error("client aborted during ordered upstream request")); + fail(Object.assign(new Error("client aborted during ordered upstream request"), { name: "AbortError" })); }; if (signal.aborted) { onAbort(); @@ -165,8 +166,8 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P if (!responseStarted) { // The request head and body are on the wire by the time `end` can // arrive (writes below are synchronous), so route it through fail(): - // the error carries `postWrite` and no retry or failover layer may - // resend it — the upstream may have processed the request already. + // the error carries `postWrite` — never failed over to another + // account; the ladder may re-send it on the same one (see fail()). fail(new Error("upstream closed before sending response headers")); return; } diff --git a/zcode-proxy-src/src/proxy/responses-handler.test.ts b/zcode-proxy-src/src/proxy/responses-handler.test.ts index a3928cc..ecf8b25 100644 --- a/zcode-proxy-src/src/proxy/responses-handler.test.ts +++ b/zcode-proxy-src/src/proxy/responses-handler.test.ts @@ -194,6 +194,10 @@ describe("handleResponses", () => { controller.enqueue(encoder.encode( `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg_cancel", type: "message", role: "assistant", model: "glm-5.2", content: [], usage: { input_tokens: 1, output_tokens: 0 } } })}\n\n`, )); + // A content event ends the prelude (the gate holds message_start until then). + controller.enqueue(encoder.encode( + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`, + )); }, }); const fetchImpl = (async (): Promise => new Response(upstreamBody, { @@ -221,7 +225,9 @@ describe("handleResponses", () => { expect(first.done).toBe(false); await expect(reader.cancel("client stopped")).resolves.toBeUndefined(); - expect(readerCancelCalls).toBe(3); + // Client reader, translator reader, the prelude gate's reader on the + // upstream body, and the upstream's own: each layer forwards the cancel. + expect(readerCancelCalls).toBe(4); } finally { ReadableStreamDefaultReader.prototype.cancel = originalReaderCancel; try { upstreamController?.close(); } catch {} diff --git a/zcode-proxy-src/src/proxy/responses-handler.ts b/zcode-proxy-src/src/proxy/responses-handler.ts index 5e32b80..1c36dae 100644 --- a/zcode-proxy-src/src/proxy/responses-handler.ts +++ b/zcode-proxy-src/src/proxy/responses-handler.ts @@ -25,6 +25,7 @@ import type { AccountHandle } from "../auth/account-rotator.js"; import { buildUpstreamRequest, buildUpstreamHeaderPairs, type UpstreamHeaderPair } from "./upstream.js"; import { isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; import { accountHandleStillCurrent, dispatchWithConnectRetry } from "./handler.js"; +import { gateStreamPrelude } from "./stream-prelude.js"; import { recoverAndMapUpstream } from "./upstream-errors.js"; import type * as CaptchaExports from "./captcha.js"; @@ -260,11 +261,29 @@ export async function handleResponses( try { // Pre-output transient ladder shared with the chat hot path (handler.ts): // fresh Request per dispatch (built inside `dispatch`), bounded backoff, - // same account, no retry once the client aborted. - upstreamResp = await dispatchWithConnectRetry(() => dispatch(upstreamHeaders), { + // same account, no retry once the client aborted, stream prelude gated. + // On start-plan a retry after a response, a drop after the write or a + // failed prelude takes a fresh pooled captcha token; a never-connected + // attempt keeps it, and a mint failure keeps the previous one. + let attemptHeaders = upstreamHeaders; + upstreamResp = await dispatchWithConnectRetry(async ({ attempt, previous }) => { + if (attempt > 1 && startPlan && previous?.kind !== "connect") { + try { + const captcha = opts.captcha ?? (await loadCaptcha()); + const token = await captcha.getCaptchaToken(opts.config.identity.appVersion); + captchaHeaders = { [captcha.RETRY_HEADERS.PARAM]: token.verifyParam, [captcha.RETRY_HEADERS.REGION]: token.region }; + attemptHeaders = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, opts.config.identity, opts.config.plan, captchaHeaders, undefined); + } catch { + // keep the previous token + } + } + return dispatch(attemptHeaders); + }, { isAborted: () => clientReq.signal.aborted, signal: clientReq.signal, beforeRetry: () => accountHandleStillCurrent(opts.auth, accountHandle), + // Responses always translates: fetch has already inflated the body. + streamPrelude: (resp) => gateStreamPrelude(resp, { decoded: true, signal: clientReq.signal }), onRetry: (attempt, reason, delayMs) => { console.log(`[responses] upstream transient failure (${reason}), retry ${attempt + 1} in ${delayMs}ms`); }, diff --git a/zcode-proxy-src/src/proxy/stream-prelude.test.ts b/zcode-proxy-src/src/proxy/stream-prelude.test.ts new file mode 100644 index 0000000..a579228 --- /dev/null +++ b/zcode-proxy-src/src/proxy/stream-prelude.test.ts @@ -0,0 +1,207 @@ +/** + * Stream prelude gate: a 200 event stream is read up to the first content + * event; an early transient error or end is reported as retryable, a content + * event hands on the held prelude plus the untouched rest byte-exactly, and + * every bound (time, size, undecodable coding) falls back to passthrough. + */ +import { describe, it, expect } from "bun:test"; +import { gzipSync } from "node:zlib"; +import { classifyStreamError, gateStreamPrelude, isGatedStream, MAX_PRELUDE_BYTES } from "./stream-prelude.js"; + +const enc = new TextEncoder(); +const START = 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n'; +const PING = 'event: ping\ndata: {"type":"ping"}\n\n'; +const BLOCK = 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'; +const DELTA = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}\n\n'; +const STOP = 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; +const OVERLOADED = 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n'; +const INVALID = 'event: error\ndata: {"type":"error","error":{"type":"invalid_request_error","message":"bad request"}}\n\n'; +const QUOTA = 'event: error\ndata: {"type":"error","error":{"type":"api_error","message":"[1005] exceed quota limit"}}\n\n'; +const GATEWAY_1302 = 'event: error\ndata: {"type":"error","error":{"type":"api_error","message":"[1302] rate limited"}}\n\n'; + +function sse(chunks: Array, opts: { failAfter?: boolean; hang?: boolean; onCancel?: () => void; headers?: Record; status?: number } = {}): Response { + let index = 0; + const body = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index++]; + controller.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk); + return; + } + if (opts.hang) return new Promise(() => {}); + if (opts.failAfter) controller.error(new Error("upstream socket reset")); + else controller.close(); + }, + cancel() { opts.onCancel?.(); }, + }); + return new Response(body, { status: opts.status ?? 200, headers: { "content-type": "text/event-stream", ...(opts.headers ?? {}) } }); +} + +describe("gateStreamPrelude", () => { + it("hands on a stream whose first content event arrived, byte-exactly and without retry", async () => { + const verdict = await gateStreamPrelude(sse([START, PING, BLOCK, DELTA, STOP])); + expect(verdict.kind).toBe("content"); + expect(verdict.retryable).toBe(false); + expect(verdict.reason).toContain("content_block_start"); + expect(await verdict.response.text()).toBe(START + PING + BLOCK + DELTA + STOP); + expect(verdict.response.headers.get("content-type")).toBe("text/event-stream"); + }); + + it("is chunk-boundary agnostic (frames split across reads, several frames in one read)", async () => { + const whole = START + PING + BLOCK + DELTA + STOP; + const bytes = enc.encode(whole); + const chunks = [bytes.subarray(0, 7), bytes.subarray(7, START.length + 3), bytes.subarray(START.length + 3)]; + const verdict = await gateStreamPrelude(sse(chunks)); + expect(verdict.kind).toBe("content"); + expect(await verdict.response.text()).toBe(whole); + }); + + it("reports a transient error event in the prelude as retryable and keeps the stream intact for the surfaced case", async () => { + const verdict = await gateStreamPrelude(sse([START, OVERLOADED])); + expect(verdict.kind).toBe("error"); + expect(verdict.retryable).toBe(true); + expect(verdict.reason).toContain("overloaded_error"); + expect(await verdict.response.text()).toBe(START + OVERLOADED); + }); + + it("never retries a terminal verdict (request error, quota code) and retries a retryable gateway code", async () => { + expect((await gateStreamPrelude(sse([START, INVALID]))).retryable).toBe(false); + expect((await gateStreamPrelude(sse([START, QUOTA]))).retryable).toBe(false); + const gateway = await gateStreamPrelude(sse([START, GATEWAY_1302])); + expect(gateway.retryable).toBe(true); + expect(gateway.reason).toContain("1302"); + }); + + it("decides at the first content event: an error after it is the client's to see, never a retry", async () => { + const verdict = await gateStreamPrelude(sse([START, BLOCK, OVERLOADED])); + expect(verdict.kind).toBe("content"); + expect(verdict.retryable).toBe(false); + expect(await verdict.response.text()).toBe(START + BLOCK + OVERLOADED); + }); + + it("reports an end or a read failure inside the prelude as a retryable drop", async () => { + const ended = await gateStreamPrelude(sse([START])); + expect(ended.kind).toBe("eof"); + expect(ended.retryable).toBe(true); + expect(await ended.response.text()).toBe(START); + const failed = await gateStreamPrelude(sse([START, PING], { failAfter: true })); + expect(failed.kind).toBe("eof"); + expect(failed.retryable).toBe(true); + await expect(failed.response.text()).rejects.toThrow(/reset/); // the failure is re-raised for the terminal-frame monitor + }); + + it("passes through when the prelude is undecided within the time limit, without losing the in-flight read", async () => { + let cancelled = false; + const verdict = await gateStreamPrelude(sse([START], { hang: true, onCancel: () => { cancelled = true; } }), { timeoutMs: 20 }); + expect(verdict.kind).toBe("passthrough"); + expect(verdict.retryable).toBe(false); + const reader = verdict.response.body!.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toBe(START); + await reader.cancel("client left"); + expect(cancelled).toBe(true); + }); + + it("cancelling the verdict's response cancels the upstream source (the retry path)", async () => { + let cancelled = false; + const verdict = await gateStreamPrelude(sse([START, OVERLOADED, PING], { hang: true, onCancel: () => { cancelled = true; } })); + expect(verdict.retryable).toBe(true); + await verdict.response.body!.cancel("retrying"); + expect(cancelled).toBe(true); + }); + + it("decodes a compressed stream for the decision and forwards it identity-encoded", async () => { + const plain = START + BLOCK + DELTA + STOP; + const verdict = await gateStreamPrelude(sse([gzipSync(Buffer.from(plain))], { headers: { "content-encoding": "gzip" } })); + expect(verdict.kind).toBe("content"); + expect(verdict.response.headers.get("content-encoding")).toBeNull(); + expect(await verdict.response.text()).toBe(plain); + const zipped = await gateStreamPrelude(sse([gzipSync(Buffer.from(START + OVERLOADED))], { headers: { "content-encoding": "gzip" } })); + expect(zipped.retryable).toBe(true); + }); + + it("leaves non-stream, non-200 and undecodable responses untouched", async () => { + const json = new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + expect((await gateStreamPrelude(json)).response).toBe(json); + expect(isGatedStream(json)).toBe(false); + const error = sse([START, OVERLOADED], { status: 503 }); + expect((await gateStreamPrelude(error)).response).toBe(error); + const unknownCoding = sse([START], { headers: { "content-encoding": "zstd" } }); + const verdict = await gateStreamPrelude(unknownCoding); + expect(verdict.kind).toBe("passthrough"); + expect(verdict.response).toBe(unknownCoding); + }); + + it("passes through an oversized prelude (a never-ending frame or too many held frames)", async () => { + const endless = "x".repeat(MAX_PRELUDE_BYTES + 1); + const noFrame = await gateStreamPrelude(sse([endless, "\n\n" + BLOCK])); + expect(noFrame.kind).toBe("passthrough"); + expect(await noFrame.response.text()).toBe(endless + "\n\n" + BLOCK); + const comments = ": keepalive " + "y".repeat(MAX_PRELUDE_BYTES) + "\n\n"; + const held = await gateStreamPrelude(sse([comments, PING, BLOCK])); + expect(held.kind).toBe("passthrough"); + expect(await held.response.text()).toBe(comments + PING + BLOCK); + }); + + it("classifies data-only frames by their JSON type like the translators (a data-only tool start is output)", async () => { + const dataOnlyTool = 'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"read","input":{}}}\n\n'; + const toolFirst = await gateStreamPrelude(sse([START, dataOnlyTool, OVERLOADED])); + expect(toolFirst.kind).toBe("content"); + expect(toolFirst.retryable).toBe(false); + const dataOnlyPing = 'data: {"type":"ping"}\n\n'; + const pingThenError = await gateStreamPrelude(sse([START, dataOnlyPing, OVERLOADED])); + expect(pingThenError.retryable).toBe(true); + const unknownData = 'data: not-json\n\n'; + expect((await gateStreamPrelude(sse([START, unknownData, OVERLOADED]))).kind).toBe("content"); // unclassifiable data counts as output + }); + + it("reads an already inflated body as it is when the transport says so (stale content-encoding)", async () => { + const plain = START + BLOCK + DELTA + STOP; + const verdict = await gateStreamPrelude(sse([plain], { headers: { "content-encoding": "gzip" } }), { decoded: true }); + expect(verdict.kind).toBe("content"); + expect(await verdict.response.text()).toBe(plain); + expect(verdict.response.headers.get("content-encoding")).toBe("gzip"); // headers handed on unchanged + }); + + it("the size bound covers held frames and a partial frame together, before any classification", async () => { + const comment = ": pad " + "c".repeat(200 * 1024) + "\n\n"; + const partial = "event: content_block_delta\ndata: " + "p".repeat(100 * 1024); + const verdict = await gateStreamPrelude(sse([comment, partial])); + expect(verdict.kind).toBe("passthrough"); + expect(verdict.retryable).toBe(false); + }); + + it("a client abort during the prelude cancels the upstream at once and is never retryable", async () => { + let cancelled = false; + const controller = new AbortController(); + const promise = gateStreamPrelude(sse([START], { hang: true, onCancel: () => { cancelled = true; } }), { signal: controller.signal }); + setTimeout(() => controller.abort(), 10); + const verdict = await promise; + expect(verdict.retryable).toBe(false); + expect(verdict.reason).toContain("aborted"); + expect(cancelled).toBe(true); + }); + + it("understands CRLF frames", async () => { + const crlf = (s: string): string => s.replace(/\n/g, "\r\n"); + const verdict = await gateStreamPrelude(sse([crlf(START), crlf(OVERLOADED)])); + expect(verdict.kind).toBe("error"); + expect(verdict.retryable).toBe(true); + expect(await verdict.response.text()).toBe(crlf(START) + crlf(OVERLOADED)); + }); +}); + +describe("classifyStreamError", () => { + it("retries the official client's transient classes and refuses terminal ones", () => { + expect(classifyStreamError('{"type":"error","error":{"type":"overloaded_error","message":"x"}}').retryable).toBe(true); + expect(classifyStreamError('{"type":"error","error":{"type":"rate_limit_error","message":"x"}}').retryable).toBe(true); + expect(classifyStreamError('{"type":"error","error":{"type":"api_error","message":"internal"}}').retryable).toBe(true); + expect(classifyStreamError('{"type":"error","error":{"type":"authentication_error","message":"x"}}').retryable).toBe(false); + expect(classifyStreamError('{"type":"error","error":{"type":"permission_error","message":"x"}}').retryable).toBe(false); + expect(classifyStreamError('{"type":"error","error":{"type":"api_error","message":"[3007] captcha verify failed"}}').retryable).toBe(false); + expect(classifyStreamError('{"code":1303,"msg":"busy"}').retryable).toBe(true); + expect(classifyStreamError('{"error":{"code":3007}}').retryable).toBe(false); + expect(classifyStreamError('{"type":"error","error":{"type":"something_new","message":"?"}}').retryable).toBe(false); + expect(classifyStreamError("not json").retryable).toBe(false); + }); +}); diff --git a/zcode-proxy-src/src/proxy/stream-prelude.ts b/zcode-proxy-src/src/proxy/stream-prelude.ts new file mode 100644 index 0000000..023e239 --- /dev/null +++ b/zcode-proxy-src/src/proxy/stream-prelude.ts @@ -0,0 +1,254 @@ +/** + * Prelude gate for upstream Anthropic SSE streams ("prelude retry"). + * + * The official client retries a stream that fails before it emitted any + * output event: an `event: error` (overloaded, rate limit, server error) or a + * cut connection arriving before the first content event is retried like a + * failed request, because nothing reached the user yet. The proxy mirrors + * that here. A 200 stream is read frame by frame until the first content + * event: `message_start`, `ping` and comment frames are held back (they carry + * no output), an error frame or an early end is reported to the transient + * ladder, and on a content event the held prelude plus the untouched rest of + * the stream are handed on. Nothing is ever replayed: a retry cancels this + * stream, and a surfaced stream is byte-identical to what the upstream sent + * (decoded when it arrived compressed, then forwarded identity-encoded). + * + * Bounds: the decision waits at most PRELUDE_DECIDE_TIMEOUT_MS per attempt + * and holds at most MAX_PRELUDE_BYTES; beyond either the stream is passed + * through unread. A frame without an `event:` line is classified by its JSON + * `type` like the translators do; data the gate cannot classify is output. + */ +import { decodeContentStream } from "./inflate.js"; +import { lastFrameEnd } from "./sse-terminal.js"; +import { RETRYABLE_GATEWAY_CODES, TERMINAL_GATEWAY_CODES } from "./gateway-codes.js"; + +export const PRELUDE_DECIDE_TIMEOUT_MS = 15_000; +export const MAX_PRELUDE_BYTES = 256 * 1024; +const DECODABLE_ENCODINGS = new Set(["gzip", "x-gzip", "deflate", "br"]); +/** Anthropic error types the official client retries (the HTTP 429/5xx class). */ +const RETRYABLE_ERROR_TYPES = new Set(["overloaded_error", "api_error", "rate_limit_error", "internal_server_error", "timeout_error"]); +/** Verdicts about the request or account: handed to the client unchanged. */ +const TERMINAL_ERROR_TYPES = new Set(["invalid_request_error", "authentication_error", "permission_error", "not_found_error", "request_too_large", "billing_error"]); +/** Frames before the first content event that carry no output. */ +const PRELUDE_EVENTS = new Set(["message_start", "ping"]); +const EMPTY = new Uint8Array(0); +const TIMEOUT = Symbol("prelude-timeout"); +const ABORTED = Symbol("prelude-aborted"); + +export interface PreludeVerdict { + kind: "content" | "error" | "eof" | "passthrough"; + /** True when the ladder may re-dispatch the request (nothing reached the client). */ + retryable: boolean; + reason: string; + /** The stream to hand on or to cancel: held prelude + rest, identity-encoded when decoded here. */ + response: Response; +} + +type ReadResult = Awaited["read"]>>; + +function concatAll(parts: readonly Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + if (total === 0) return EMPTY; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +/** Split text that ends exactly at a frame boundary into its frames (blank-line separated). */ +function splitFrames(text: string): string[] { + return text.split(/\r\n\r\n|\n\n|\r\r/).filter((frame) => frame.length > 0); +} + +/** + * Event name of a frame as the downstream translators see it: the `event:` + * field, else the JSON `type` of the data (data-only frames are dispatched on + * it). `undefined` only for frames without data (comments, keep-alives); + * `"?"` for data the gate cannot classify — treated as output, never skipped. + */ +function parseFrame(frame: string): { event?: string; data: string } { + let event: string | undefined; + const data: string[] = []; + for (const line of frame.split(/\r\n|\r|\n/)) { + if (!line || line.startsWith(":")) continue; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") event = value; + else if (field === "data") data.push(value); + } + const joined = data.join("\n"); + if (event === undefined && joined.length) { + try { + const type = (JSON.parse(joined) as { type?: unknown })?.type; + event = typeof type === "string" ? type : "?"; + } catch { + event = "?"; + } + } + return { event, data: joined }; +} + +/** + * Classify the payload of an `event: error` frame: retried like the official + * client (transient error types and its retryable gateway codes), or handed + * to the client (terminal codes and request/auth verdicts; unknown shapes). + */ +export function classifyStreamError(data: string): { retryable: boolean; reason: string } { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return { retryable: false, reason: "error event (unparseable)" }; + } + const outer = parsed && typeof parsed === "object" ? (parsed as Record) : {}; + const error = outer.error && typeof outer.error === "object" ? (outer.error as Record) : outer; + const type = typeof error.type === "string" ? error.type : ""; + const message = typeof error.message === "string" ? error.message : ""; + const bracket = /^\[(\d{3,4})\]/.exec(message); + const code = bracket ? Number(bracket[1]) + : Number.isInteger(error.code) ? (error.code as number) + : Number.isInteger(outer.code) ? (outer.code as number) + : undefined; + if (code !== undefined && TERMINAL_GATEWAY_CODES.has(code)) return { retryable: false, reason: `error event: gateway code ${code}` }; + if (code !== undefined && RETRYABLE_GATEWAY_CODES.has(code)) return { retryable: true, reason: `error event: gateway code ${code}` }; + if (TERMINAL_ERROR_TYPES.has(type)) return { retryable: false, reason: `error event: ${type}` }; + if (RETRYABLE_ERROR_TYPES.has(type)) return { retryable: true, reason: `error event: ${type}` }; + return { retryable: false, reason: `error event: ${type || "unknown"}` }; +} + +/** True for a 200 response whose body is an event stream (the only shape the gate reads). */ +export function isGatedStream(resp: Response): boolean { + return resp.status === 200 && (resp.headers.get("content-type") ?? "").toLowerCase().includes("text/event-stream") && resp.body !== null; +} + +/** + * Rebuild the client-facing body: the held prelude first, then the untouched + * rest read from `reader` (an in-flight read is consumed first, never lost; + * a read failure seen in the prelude is re-raised so the terminal-frame + * monitor downstream can report it). Cancel propagates to the source. + */ +function reconstruct( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, + inflight: Promise | undefined, + failure: { error: unknown } | undefined, +): ReadableStream { + let prefixSent = false; + return new ReadableStream({ + async pull(controller) { + if (!prefixSent) { + prefixSent = true; + if (prefix.length) { + controller.enqueue(prefix); + return; + } + } + if (failure) { + controller.error(failure.error); + return; + } + let result: ReadResult; + try { + result = inflight ? await inflight : await reader.read(); + } catch (err) { + controller.error(err); + return; + } + inflight = undefined; + if (result.done) { + controller.close(); + return; + } + controller.enqueue(result.value); + }, + cancel(reason) { + return reader.cancel(reason).catch(() => {}); + }, + }); +} + +/** + * Read the prelude of a 200 event stream and decide whether it failed before + * any output. The returned response replaces `resp` (whose body is consumed). + * `decoded`: the transport already inflated the body (fetch in translation + * mode may keep a stale `content-encoding` header) — then the bytes are read + * as they are and the headers are handed on unchanged. + */ +export async function gateStreamPrelude(resp: Response, opts: { timeoutMs?: number; maxPreludeBytes?: number; decoded?: boolean; signal?: AbortSignal } = {}): Promise { + if (!isGatedStream(resp)) return { kind: "passthrough", retryable: false, reason: "not a 200 event stream", response: resp }; + const encoding = (resp.headers.get("content-encoding") ?? "").toLowerCase(); + const codings = opts.decoded ? [] : encoding.split(",").map((c) => c.trim()).filter((c) => c && c !== "identity"); + let headers = resp.headers; + let source = resp.body as ReadableStream; + if (codings.length) { + if (!codings.every((c) => DECODABLE_ENCODINGS.has(c))) return { kind: "passthrough", retryable: false, reason: "undecodable content-encoding", response: resp }; + source = decodeContentStream(source, encoding); + headers = new Headers(resp.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + } + const reader = source.getReader(); + const decoder = new TextDecoder(); + const held: Uint8Array[] = []; + let heldBytes = 0; + let pending: Uint8Array = EMPTY; + const maxBytes = opts.maxPreludeBytes ?? MAX_PRELUDE_BYTES; + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(TIMEOUT), opts.timeoutMs ?? PRELUDE_DECIDE_TIMEOUT_MS); }); + timer?.unref?.(); + // A client that leaves during the prelude must not keep the upstream open + // until the time limit: the source is cancelled at once. + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + if (!opts.signal) return; + if (opts.signal.aborted) { resolve(ABORTED); return; } + onAbort = () => resolve(ABORTED); + opts.signal.addEventListener("abort", onAbort, { once: true }); + }); + const finish = (kind: PreludeVerdict["kind"], retryable: boolean, reason: string, inflight?: Promise, failure?: { error: unknown }): PreludeVerdict => { + if (timer) clearTimeout(timer); + if (onAbort) opts.signal?.removeEventListener("abort", onAbort); + const body = reconstruct(concatAll([...held, pending]), reader, inflight, failure); + return { kind, retryable, reason, response: new Response(body, { status: resp.status, statusText: resp.statusText, headers }) }; + }; + for (;;) { + const inflight = reader.read(); + const raced = await Promise.race([ + inflight.then((read) => ({ read }), (error: unknown) => ({ failure: { error } })), + timeout, + aborted, + ]); + if (raced === ABORTED) { + void reader.cancel(opts.signal?.reason).catch(() => {}); + return finish("passthrough", false, "client aborted during the prelude", undefined, { error: new Error("client aborted") }); + } + if (raced === TIMEOUT) return finish("passthrough", false, "prelude undecided within the time limit", inflight); + if ("failure" in raced) return finish("eof", true, "stream failed in the prelude", undefined, raced.failure); + const { done, value } = raced.read; + if (done) return finish("eof", true, "stream ended in the prelude"); + pending = pending.length ? concatAll([pending, value]) : value; + // One bound for everything held back (complete frames + a partial one), + // checked before any classification: past it the stream is committed. + if (heldBytes + pending.length > maxBytes) return finish("passthrough", false, "prelude too large"); + const end = lastFrameEnd(pending); + if (end === 0) continue; + const complete = pending.subarray(0, end); + pending = end < pending.length ? pending.slice(end) : EMPTY; + held.push(complete); + heldBytes += complete.length; + for (const frame of splitFrames(decoder.decode(complete, { stream: true }))) { + const { event, data } = parseFrame(frame); + if (event === "error") { + const verdict = classifyStreamError(data); + return finish("error", verdict.retryable, verdict.reason); + } + if (event === undefined || PRELUDE_EVENTS.has(event)) continue; + return finish("content", false, `first content event: ${event}`); + } + } +} diff --git a/zcode-proxy-src/src/proxy/transient-retry.test.ts b/zcode-proxy-src/src/proxy/transient-retry.test.ts index d44becd..3ea4569 100644 --- a/zcode-proxy-src/src/proxy/transient-retry.test.ts +++ b/zcode-proxy-src/src/proxy/transient-retry.test.ts @@ -249,8 +249,11 @@ describe("transient pre-output retry ladder", () => { expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNREFUSED" }))?.kind).toBe("connect"); expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }))?.kind).toBe("drop"); expect(transientErrorKind(new Error("socket hang up"))?.kind).toBe("drop"); - expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNRESET", postWrite: true }))).toBeNull(); - expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("y"), { postWrite: true }) }))).toBeNull(); + // postWrite (ordered transport wrote the request): a drop like on the fetch path, never a "connect" + expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNRESET", postWrite: true }))?.kind).toBe("drop"); + expect(transientErrorKind(Object.assign(new Error("x"), { code: "ECONNREFUSED", postWrite: true }))?.kind).toBe("drop"); + expect(transientErrorKind(Object.assign(new Error("upstream closed before sending response headers"), { postWrite: true }))?.kind).toBe("drop"); + expect(transientErrorKind(Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("y"), { postWrite: true }) }))).toBeNull(); // unknown failure shape expect(transientErrorKind(Object.assign(new Error("cert"), { code: "CERT_HAS_EXPIRED" }))).toBeNull(); expect(transientErrorKind(Object.assign(new Error("socket"), { code: "UND_ERR_SOCKET", cause: Object.assign(new Error("cert"), { code: "CERT_HAS_EXPIRED" }) }))).toBeNull(); // TLS anywhere in the chain wins expect(transientErrorKind(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBeNull(); diff --git a/zcode-proxy-src/src/proxy/upstream-errors.ts b/zcode-proxy-src/src/proxy/upstream-errors.ts index 7f43d86..8d1df33 100644 --- a/zcode-proxy-src/src/proxy/upstream-errors.ts +++ b/zcode-proxy-src/src/proxy/upstream-errors.ts @@ -205,9 +205,10 @@ export async function recoverAndMapUpstream(opts: { } catch (err) { // A failure after the full request was written (flag on the error or // a nested cause) may have been processed upstream: never fail over - // to another account on top of it (the transient ladder refuses the - // same replay). The handler maps the rethrown error to a 502 without - // a further attempt; the original envelope body is released first. + // to another account on top of it (the ladder only ever re-sends on + // the same account, and it does not wrap this resend). The handler + // maps the rethrown error to a 502 without a further attempt; the + // original envelope body is released first. if (isPostWriteError(err)) { void response.body?.cancel().catch(() => {}); throw err; diff --git a/zcode-proxy-src/src/server/protocol-contract.test.ts b/zcode-proxy-src/src/server/protocol-contract.test.ts index 825d4bf..95da3d2 100644 --- a/zcode-proxy-src/src/server/protocol-contract.test.ts +++ b/zcode-proxy-src/src/server/protocol-contract.test.ts @@ -185,10 +185,12 @@ describe("protocol contract: /v1/messages (anthropic passthrough)", () => { const upstream = (async (req: Request | URL | string, init?: RequestInit): Promise => { const sig = init?.signal ?? (req instanceof Request ? req.signal : undefined); if (sig) signals.push(sig); - // Stream that stays open until the upstream fetch is aborted. + // Stream that stays open until the upstream fetch is aborted. The first + // content event ends the prelude, so the response headers go out. const stream = new ReadableStream({ start(controller) { controller.enqueue(enc.encode(sse("message_start", { type: "message_start", message: { id: "m", type: "message", role: "assistant", model: "glm-5.3-flash", content: [], usage: { input_tokens: 1, output_tokens: 0 } } }))); + controller.enqueue(enc.encode(sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }))); }, }); return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); @@ -218,6 +220,44 @@ describe("protocol contract: /v1/messages (anthropic passthrough)", () => { } }); + it("a client that leaves while the stream prelude is still open cancels the upstream at once (no wait for the prelude limit)", async () => { + let upstreamCancelled = false; + let dispatches = 0; + const upstream = (async (): Promise => { + dispatches += 1; + // message_start only, then silence: the prelude gate is still deciding. + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(sse("message_start", { type: "message_start", message: { id: "m", type: "message", role: "assistant", model: "glm-5.3-flash", content: [], usage: { input_tokens: 1, output_tokens: 0 } } }))); + }, + cancel() { upstreamCancelled = true; }, + }); + return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as unknown as typeof fetch; + const server = await startServer({ config: makeConfig({ server: { port: 0, host: "127.0.0.1" }, auth: { proxyApiKey: "contract-test-key" } }), auth: oauthAuth(), fetchImpl: upstream }); + const ctrl = new AbortController(); + try { + const pending = fetch(`http://127.0.0.1:${server.port}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json", accept: "text/event-stream", authorization: "Bearer contract-test-key" }, + body: JSON.stringify({ model: "glm-5.3-flash", max_tokens: 100, stream: true, messages: [{ role: "user", content: "hi" }] }), + signal: ctrl.signal, + }).catch(() => null); + const started = Date.now(); + while (dispatches === 0 && Date.now() - started < 3000) await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 50)); + ctrl.abort(); + await pending; + const deadline = Date.now() + 4000; + while (Date.now() < deadline && !upstreamCancelled) await new Promise((r) => setTimeout(r, 25)); + expect(upstreamCancelled).toBe(true); + expect(dispatches).toBe(1); // an abort is never retried + } finally { + server.stop(); + await server.close(); + } + }); + it("forwards image blocks to the upstream unchanged (flash multimodal)", async () => { const capture: { bodies: string[]; signals: AbortSignal[] } = { bodies: [], signals: [] }; const upstream = anthropicStreamUpstream(sseChunks(anthropicStreamEvents("Rot.")), capture); From ff85d69d457328641e04c6b03671170f0b8b135a Mon Sep 17 00:00:00 2001 From: ZepiGit Date: Sun, 27 Sep 2026 18:01:01 +0000 Subject: [PATCH 6/6] fix: review and CI findings on package 5 (installer exit code, mirror safety, prelude edge cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (first run on Linux and Windows): - install.ps1 left $LASTEXITCODE at setup's 20/130 after reporting the installation as complete; callers checking it saw a failure. It is now reset to 0 once the installation succeeded. - Installer tests: the rsync fixture now has older installed files (rsync's size+mtime quick check skipped same-second, same-size files); the robocopy fixture ships proxy/ like a real release (/MIR purges a directory the release no longer ships, protected files inside included). Final review: - install.sh portable mirror: the archive goes through a file, so a failed or partial copy step stops before any delete (no pipefail in sh). - Memory restart: close() is bounded to 6 s so the 3 s metadata write still runs inside the 10 s exit cap. - Prelude gate: a body that cannot be decoded is handed on instead of retried; frames with mixed line endings split exactly like the frame-end scanner; the ladder stops at once when the client left during the prelude. - doctor --upstream: the response size cap applies while reading; the reference-version fallback runs only for covered plans; at most three requests (docs corrected in all languages). - doctor --forget: the setup lock is released even if the transaction cannot start; setup --select answers are trimmed. Documentation counter-check: --select needs a terminal and yields to an explicit selection; status --json carries quota only while the kit's own proxy runs (exit 0 only then) — README in all five languages, CLI usage. --- README.de.md | 6 ++-- README.es.md | 6 ++-- README.ja.md | 6 ++-- README.md | 6 ++-- README.zh-CN.md | 6 ++-- cli/harness-consent.mjs | 2 +- cli/upstream-config.mjs | 33 +++++++++++++++---- cli/zcode-kit.mjs | 7 ++-- install.ps1 | 3 ++ install.sh | 8 +++-- tests/installer-audit.test.mjs | 11 +++++-- zcode-proxy-src/src/index.ts | 6 +++- zcode-proxy-src/src/proxy/handler.ts | 6 ++++ .../src/proxy/stream-prelude.test.ts | 13 ++++++++ zcode-proxy-src/src/proxy/stream-prelude.ts | 16 +++++++-- 15 files changed, 102 insertions(+), 33 deletions(-) diff --git a/README.de.md b/README.de.md index 2354c52..e6fa80e 100644 --- a/README.de.md +++ b/README.de.md @@ -175,10 +175,10 @@ zcode-kit auth status - **Befehl nicht gefunden:** Terminal neu öffnen. Bei Release-Installationen prüfen, ob `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) oder `$HOME/.local/bin` (macOS/Linux) im PATH steht. - **Assistent beim Setup übersprungen:** Beim nächsten Mal `y` antworten, `zcode-kit integrate ` ausführen (`zcode-kit setup --harness ` für mehrere) oder mit `zcode-kit setup --reask` alle erkannten Assistenten erneut abfragen lassen. Ohne Terminal Assistenten mit `ZCODE_KIT_HARNESSES` auswählen. - **Manuelle Client-Einrichtung:** `zcode-kit proxy status` gibt Basis-URLs, Schlüssel und Modell-IDs aus, solange der Proxy läuft; den vollständigen Schlüssel nur in einem interaktiven Terminal (sonst `zcode-kit models --show-key`). -- **Mehrere Assistenten auf einmal wählen:** `zcode-kit setup --select` zeigt eine nummerierte Liste der erkannten Assistenten statt einer Frage pro Assistent (`1,3`, `all` oder `none`); die gewählten werden eingerichtet, die übrigen erkannten als übersprungen gespeichert. +- **Mehrere Assistenten auf einmal wählen:** `zcode-kit setup --select` zeigt eine nummerierte Liste der erkannten Assistenten statt einer Frage pro Assistent (`1,3`, `all` oder `none`); die gewählten werden eingerichtet, die übrigen erkannten als übersprungen gespeichert. Das braucht ein Terminal, wird neben einer expliziten Auswahl per `--harness`/`ZCODE_KIT_HARNESSES` ignoriert und ersetzt die gespeicherte Antwort jedes erkannten Assistenten. - **Gespeicherte Antwort vergessen:** `zcode-kit doctor` meldet unlesbare oder verwaiste Entscheidungsdateien; `zcode-kit doctor --forget ` entfernt eine davon (die Integration bleibt, das nächste Setup fragt erneut; rückgängig mit `zcode-kit rollback`). -- **Skripte und Monitoring:** `zcode-kit proxy status --json` gibt ein JSON-Objekt mit Zustand, Basis-URLs, Modell-IDs und Kontingent aus; der Schlüssel ist nie enthalten. -- **Gateway beim Anbieter umgezogen?** `zcode-kit doctor --upstream` vergleicht das Gateway, das der Proxy des Kits nutzt, mit der Provider-Konfiguration, die der ZCode-Client aktuell erhält (zwei Anfragen an zcode.z.ai, ohne Zugangsdaten; nur auf ausdrücklichen Wunsch). +- **Skripte und Monitoring:** `zcode-kit proxy status --json` gibt ein JSON-Objekt mit Zustand, Basis-URLs und Modell-IDs aus, dazu das Kontingent, solange der eigene Proxy des Kits läuft (nur dann Exit 0); der Schlüssel ist nie enthalten. +- **Gateway beim Anbieter umgezogen?** `zcode-kit doctor --upstream` vergleicht das Gateway, das der Proxy des Kits nutzt, mit der Provider-Konfiguration, die der ZCode-Client aktuell erhält (höchstens drei Anfragen ohne Zugangsdaten an zcode.z.ai und sein CDN; nur auf ausdrücklichen Wunsch). - **Keine Modellantwort:** Desktop-Login und verfügbares Kontingent prüfen. Proxy starten, wenn dein Client ihn nicht startet. Eine lokale Gesundheitsprüfung beweist keinen Modellzugriff. - **Proxy aus oder antwortet nicht:** `zcode-kit doctor --fix` oder `zcode-kit proxy restart` ausführen. Beendet wird nur ein nachweislich eigener, hängender Proxy; siehe den Abschnitt zum manuellen Proxy-Betrieb oben. - **OMP-Autostart:** OMP direkt starten. Das Setup fixiert natives Node/Bun; die Erweiterung führt die Vorprüfung in einem neuen Kindprozess aus, statt Kit-Module in OMP zu importieren. Fehler melden Kategorien ohne Geheimnisse; der Kindprozess ist auf 120 Sekunden begrenzt. Ursache beheben und nach der sitzungsbezogenen Wartezeit von 60 Sekunden erneut versuchen; eine Wiederherstellung ist in derselben Sitzung möglich. Wurde die Laufzeit verschoben, `zcode-kit setup --harness auto` erneut ausführen und die Erweiterung neu laden. Unbekannte Portbesitzer bleiben unberührt. diff --git a/README.es.md b/README.es.md index b614d2d..14a3974 100644 --- a/README.es.md +++ b/README.es.md @@ -175,10 +175,10 @@ zcode-kit auth status - **No se encuentra el comando:** vuelve a abrir la terminal. En instalaciones de una versión publicada, comprueba que `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) o `$HOME/.local/bin` (macOS/Linux) esté en PATH. - **Asistente omitido durante la configuración:** responde `y` la próxima vez, ejecuta `zcode-kit integrate ` (`zcode-kit setup --harness ` para varios) o `zcode-kit setup --reask` para que vuelva a preguntar por cada asistente detectado. Sin terminal, selecciona los asistentes con `ZCODE_KIT_HARNESSES`. - **Configuración manual del cliente:** `zcode-kit proxy status` imprime las URL base, la clave y los ID de modelo mientras el proxy está en ejecución; la clave completa solo en una terminal interactiva (`zcode-kit models --show-key` en otro caso). -- **Elegir varios asistentes a la vez:** `zcode-kit setup --select` muestra una lista numerada de los asistentes detectados en lugar de una pregunta por asistente (`1,3`, `all` o `none`); los elegidos se configuran y los demás detectados se guardan como omitidos. +- **Elegir varios asistentes a la vez:** `zcode-kit setup --select` muestra una lista numerada de los asistentes detectados en lugar de una pregunta por asistente (`1,3`, `all` o `none`); los elegidos se configuran y los demás detectados se guardan como omitidos. Necesita una terminal, se ignora junto a una selección explícita con `--harness`/`ZCODE_KIT_HARNESSES` y sustituye la respuesta guardada de cada asistente detectado. - **Olvidar una respuesta guardada:** `zcode-kit doctor` informa de archivos de decisión ilegibles o huérfanos; `zcode-kit doctor --forget ` elimina uno (la integración se mantiene y la siguiente configuración vuelve a preguntar; se deshace con `zcode-kit rollback`). -- **Scripts y monitorización:** `zcode-kit proxy status --json` imprime un objeto JSON con el estado, las URL base, los ID de modelo y la cuota; nunca contiene la clave. -- **¿El proveedor cambió un gateway?** `zcode-kit doctor --upstream` compara el gateway que usa el proxy del kit con la configuración de proveedores que recibe actualmente el cliente de ZCode (dos solicitudes a zcode.z.ai, sin credenciales; solo si lo pides). +- **Scripts y monitorización:** `zcode-kit proxy status --json` imprime un objeto JSON con el estado, las URL base y los ID de modelo, y la cuota mientras el proxy propio del kit está en ejecución (solo entonces sale con 0); nunca contiene la clave. +- **¿El proveedor cambió un gateway?** `zcode-kit doctor --upstream` compara el gateway que usa el proxy del kit con la configuración de proveedores que recibe actualmente el cliente de ZCode (como mucho tres solicitudes sin credenciales a zcode.z.ai y su CDN; solo si lo pides). - **No responde el modelo:** comprueba la sesión de Desktop y la cuota disponible. Inicia el proxy si tu cliente no lo hace. Una comprobación de salud local no demuestra acceso al modelo. - **Proxy detenido o sin respuesta:** ejecuta `zcode-kit doctor --fix` o `zcode-kit proxy restart`. Solo se termina un proxy bloqueado cuya propiedad por el kit esté demostrada; consulta la sección de gestión manual del proxy más arriba. - **Autoarranque de OMP:** ejecuta OMP directamente. La configuración fija Node/Bun nativos; la extensión realiza la comprobación previa en un proceso hijo nuevo, sin importar módulos del kit en OMP. Los fallos muestran categorías sin secretos; el proceso hijo tiene un límite de 120 segundos. Corrige la causa indicada y reintenta tras los 60 segundos de espera por sesión; es posible recuperarse en la misma sesión. Si cambió la ubicación del runtime, ejecuta de nuevo `zcode-kit setup --harness auto` y recarga la extensión. No se modifican procesos desconocidos que ocupen el puerto. diff --git a/README.ja.md b/README.ja.md index 05101b3..43097bd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -175,10 +175,10 @@ zcode-kit auth status - **コマンドが見つからない:** ターミナルを開き直してください。リリース版のインストールでは、`%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) または `$HOME/.local/bin` (macOS/Linux) が PATH にあるか確認します。 - **セットアップでアシスタントをスキップした:** 次回 `y` と答えるか、`zcode-kit integrate <アシスタント>`(複数なら `zcode-kit setup --harness <リスト>`)を実行するか、`zcode-kit setup --reask` で検出したすべてのアシスタントについて改めて質問させます。ターミナルがない場合は `ZCODE_KIT_HARNESSES` でアシスタントを選択します。 - **クライアントの手動設定:** `zcode-kit proxy status` はプロキシの実行中にベース URL、キー、モデル ID を表示します。完全なキーは対話的なターミナルでのみ表示されます(それ以外は `zcode-kit models --show-key`)。 -- **複数のアシスタントをまとめて選ぶ:** `zcode-kit setup --select` は、アシスタントごとの質問の代わりに、検出されたアシスタントの番号付きリストを 1 つ表示します(`1,3`、`all`、`none`)。選んだものは設定され、選ばなかった検出済みのものはスキップとして記録されます。 +- **複数のアシスタントをまとめて選ぶ:** `zcode-kit setup --select` は、アシスタントごとの質問の代わりに、検出されたアシスタントの番号付きリストを 1 つ表示します(`1,3`、`all`、`none`)。選んだものは設定され、選ばなかった検出済みのものはスキップとして記録されます。ターミナルが必要で、`--harness`/`ZCODE_KIT_HARNESSES` による明示的な選択があるときは無視され、検出されたすべてのアシスタントの保存済みの回答を置き換えます。 - **保存した回答を忘れる:** `zcode-kit doctor` は読めない決定ファイルや対象のない決定ファイルを報告します。`zcode-kit doctor --forget <アシスタント>` でそれを削除できます(統合はそのまま残り、次のセットアップで再び質問されます。`zcode-kit rollback` で元に戻せます)。 -- **スクリプトと監視:** `zcode-kit proxy status --json` は、状態、ベース URL、モデル ID、クォータを含む JSON オブジェクトを 1 つ出力します。キーは含まれません。 -- **ベンダーがゲートウェイを移した?** `zcode-kit doctor --upstream` は、kit のプロキシが使うゲートウェイを、ZCode クライアントが現在受け取るプロバイダー設定と比較します(zcode.z.ai への 2 回のリクエスト、認証情報なし。明示的に指定した場合のみ)。 +- **スクリプトと監視:** `zcode-kit proxy status --json` は、状態、ベース URL、モデル ID を含む JSON オブジェクトを 1 つ出力します。kit 自身のプロキシが動作中のときだけクォータも含まれ、そのときだけ終了コード 0 になります。キーは含まれません。 +- **ベンダーがゲートウェイを移した?** `zcode-kit doctor --upstream` は、kit のプロキシが使うゲートウェイを、ZCode クライアントが現在受け取るプロバイダー設定と比較します(zcode.z.ai とその CDN への認証情報なしのリクエストが最大 3 回。明示的に指定した場合のみ)。 - **モデルから返答がない:** Desktop のログインと残りの利用枠を確認します。クライアントがプロキシを起動しない場合は、手動で起動します。ローカルのヘルスチェックだけではモデルへのアクセスは確認できません。 - **プロキシが停止している、または応答しない:** `zcode-kit doctor --fix` または `zcode-kit proxy restart` を実行します。終了されるのは、kit 自身のものと証明された応答のないプロキシだけです。上の手動操作のセクションを参照してください。 - **OMP の自動起動:** OMP を直接起動します。セットアップでネイティブの Node/Bun を固定し、拡張機能は kit モジュールを OMP にインポートせず、新しい子プロセスで事前確認を行います。失敗時は機密情報を含まないカテゴリを表示し、子プロセスの実行時間は最大 120 秒です。原因を修正し、セッションごとの 60 秒の待機時間後に再試行してください。同じセッション内で復旧できます。ランタイムを移動した場合は `zcode-kit setup --harness auto` を再実行し、拡張機能を再読み込みします。ポートを使用している不明なプロセスには干渉しません。 diff --git a/README.md b/README.md index e6d7616..667a43a 100644 --- a/README.md +++ b/README.md @@ -175,10 +175,10 @@ zcode-kit auth status - **Command not found:** reopen the terminal. For release installs, check that `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows) or `$HOME/.local/bin` (macOS/Linux) is on PATH. - **Assistant skipped during setup:** answer `y` next time, run `zcode-kit integrate ` (`zcode-kit setup --harness ` for several), or `zcode-kit setup --reask` to be asked again for every detected assistant. Without a terminal, select assistants with `ZCODE_KIT_HARNESSES`. - **Manual client setup:** `zcode-kit proxy status` prints the base URLs, key and model IDs while the proxy runs; the full key only in an interactive terminal (`zcode-kit models --show-key` otherwise). -- **Choose several assistants at once:** `zcode-kit setup --select` shows one numbered list of the detected assistants instead of one question each (`1,3`, `all` or `none`); the chosen ones are configured, the other detected ones are recorded as skipped. +- **Choose several assistants at once:** `zcode-kit setup --select` shows one numbered list of the detected assistants instead of one question each (`1,3`, `all` or `none`); the chosen ones are configured, the other detected ones are recorded as skipped. It needs a terminal, is ignored next to an explicit `--harness`/`ZCODE_KIT_HARNESSES` selection, and replaces the stored answer of every detected assistant. - **Forget a stored answer:** `zcode-kit doctor` reports unreadable or orphaned decision files; `zcode-kit doctor --forget ` removes one (the integration stays, the next setup asks again; undo with `zcode-kit rollback`). -- **Scripts and monitoring:** `zcode-kit proxy status --json` prints one JSON object with health, base URLs, model IDs and quota; it never contains the key. -- **Vendor moved a gateway?** `zcode-kit doctor --upstream` compares the gateway the kit's proxy uses with the provider configuration the ZCode client currently receives (two requests to zcode.z.ai, no credentials; only when you ask for it). +- **Scripts and monitoring:** `zcode-kit proxy status --json` prints one JSON object with health, base URLs and model IDs, plus quota while the kit's own proxy is running (exit 0 only then); it never contains the key. +- **Vendor moved a gateway?** `zcode-kit doctor --upstream` compares the gateway the kit's proxy uses with the provider configuration the ZCode client currently receives (at most three unauthenticated requests to zcode.z.ai and its CDN; only when you ask for it). - **No model reply:** check the Desktop login and available quota. Start the proxy if your client does not start it. Local health does not prove model access. - **Proxy down or not answering:** run `zcode-kit doctor --fix` or `zcode-kit proxy restart`. Only a proven-own hung proxy is terminated; see the manual proxy section above. - **OMP autostart:** launch OMP directly. Setup pins native Node/Bun; the extension runs preflight in a fresh child instead of importing kit modules into OMP. Failures report secret-free categories; the child is limited to 120 seconds. Fix the reported cause, then retry after the 60-second per-session cooldown; recovery is possible in the same session. If the runtime moved, rerun `zcode-kit setup --harness auto` and reload the extension. Unknown port owners are left untouched. diff --git a/README.zh-CN.md b/README.zh-CN.md index 31bd8e4..672bed9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,10 +175,10 @@ zcode-kit auth status - **找不到命令:**重新打开终端。对于正式版安装,请检查 `%LOCALAPPDATA%\Microsoft\WindowsApps`(Windows)或 `$HOME/.local/bin`(macOS/Linux)是否位于 PATH 中。 - **设置时跳过了助手:**下次回答 `y`,运行 `zcode-kit integrate <助手>`(多个助手用 `zcode-kit setup --harness <列表>`),或运行 `zcode-kit setup --reask` 对每个检测到的助手重新提问。没有终端时,用 `ZCODE_KIT_HARNESSES` 选择助手。 - **手动配置客户端:**代理运行期间,`zcode-kit proxy status` 会输出基础 URL、密钥和模型 ID;完整密钥只在交互式终端显示(否则使用 `zcode-kit models --show-key`)。 -- **一次选择多个助手:**`zcode-kit setup --select` 会显示检测到的助手的编号列表,而不是逐个提问(输入 `1,3`、`all` 或 `none`);选中的会被配置,其余检测到的记录为已跳过。 +- **一次选择多个助手:**`zcode-kit setup --select` 会显示检测到的助手的编号列表,而不是逐个提问(输入 `1,3`、`all` 或 `none`);选中的会被配置,其余检测到的记录为已跳过。它需要终端;与通过 `--harness`/`ZCODE_KIT_HARNESSES` 进行的显式选择同时使用时会被忽略,并会替换每个检测到的助手已保存的回答。 - **忘记已保存的回答:**`zcode-kit doctor` 会报告无法读取或没有对应助手的决定文件;`zcode-kit doctor --forget <助手>` 可删除其中一个(集成保持不变,下次设置会再次询问;可用 `zcode-kit rollback` 撤销)。 -- **脚本与监控:**`zcode-kit proxy status --json` 输出一个包含状态、基础 URL、模型 ID 和配额的 JSON 对象;其中从不包含密钥。 -- **服务商迁移了网关?**`zcode-kit doctor --upstream` 会将 Kit 代理使用的网关与 ZCode 客户端当前收到的服务商配置进行比较(向 zcode.z.ai 发送两个请求,不带凭据;仅在显式指定时运行)。 +- **脚本与监控:**`zcode-kit proxy status --json` 输出一个包含状态、基础 URL 和模型 ID 的 JSON 对象;仅当 Kit 自己的代理正在运行时才包含配额(也只有此时退出码为 0);其中从不包含密钥。 +- **服务商迁移了网关?**`zcode-kit doctor --upstream` 会将 Kit 代理使用的网关与 ZCode 客户端当前收到的服务商配置进行比较(最多向 zcode.z.ai 及其 CDN 发送三个不带凭据的请求;仅在显式指定时运行)。 - **模型没有回复:**检查 Desktop 登录状态和可用额度。如果客户端不会自动启动代理,请手动启动。本地健康检查不能证明模型可访问。 - **代理未运行或无响应:**运行 `zcode-kit doctor --fix` 或 `zcode-kit proxy restart`。只会终止经证明属于本 Kit 的挂起代理;参阅上方手动管理代理部分。 - **OMP 自启动:**直接启动 OMP。设置过程固定原生 Node/Bun;扩展在新的子进程中执行预检查,而不是将 Kit 模块导入 OMP。失败时会报告不含秘密信息的错误类别;子进程最长运行 120 秒。修复所报告的原因后,等待该会话的 60 秒冷却时间再重试;可在同一会话中恢复。若运行时位置已变更,请重新运行 `zcode-kit setup --harness auto` 并重新加载扩展。不会干预占用端口的未知进程。 diff --git a/cli/harness-consent.mjs b/cli/harness-consent.mjs index 5967b8f..beb5364 100644 --- a/cli/harness-consent.mjs +++ b/cli/harness-consent.mjs @@ -141,7 +141,7 @@ export function createPrompter({ input = process.stdin, output = process.stdout, * set of 0-based indexes, or undefined when the answer is invalid. */ export function parseSelection(answer, count) { - const text = answer.toLowerCase(); + const text = answer.trim().toLowerCase(); if (text === "all") return new Set([...Array(count).keys()]); if (text === "none") return new Set(); const parts = text.split(/[\s,]+/).filter(Boolean); diff --git a/cli/upstream-config.mjs b/cli/upstream-config.mjs index c0923c3..5b4da7a 100644 --- a/cli/upstream-config.mjs +++ b/cli/upstream-config.mjs @@ -7,8 +7,9 @@ // and overrides its bundled table with it; older app versions get a provider // list in data.providers[] instead. When the vendor moves a gateway, the // client follows at once while the kit keeps its compiled constants — this -// check makes that visible. Opt-in (two unauthenticated GETs to the vendor), -// never part of the default doctor; nothing secret is sent. +// check makes that visible. Opt-in (at most three unauthenticated GETs to +// the vendor and its CDN), never part of the default doctor; nothing secret +// is sent. import { existsSync, readFileSync } from "node:fs"; import { isIP } from "node:net"; import { configuredModelIds } from "./connection-details.mjs"; @@ -65,9 +66,28 @@ export function resolveUpstreamOrigin(env = process.env) { async function getJson(url, fetchImpl, signal) { const res = await fetchImpl(url, { method: "GET", redirect: "error", credentials: "omit", signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const text = await res.text(); - if (text.length > MAX_BYTES) throw new Error("response too large"); - return JSON.parse(text); + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_BYTES) { + void res.body?.cancel().catch(() => {}); + throw new Error("response too large"); + } + // Read with a byte cap: the header may be absent or wrong. + const chunks = []; + let total = 0; + const reader = res.body?.getReader(); + if (reader) { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BYTES) { + void reader.cancel().catch(() => {}); + throw new Error("response too large"); + } + chunks.push(value); + } + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); } /** @@ -168,7 +188,8 @@ export async function upstreamChecks(configPath, { fetchImpl = fetch, env = proc // Older app versions get a provider list without the plan gateways; the // current client's view is what the vendor actually routes, so compare // against it as well when the configured version says nothing. - if (checks.length === 1 && checks[0].ok === null && upstream.shape === "legacy" && target.appVersion !== REFERENCE_APP_VERSION) { + const covered = Boolean(KIT_GATEWAY_BASES[target.plan]?.[target.provider]); + if (covered && checks.length === 1 && checks[0].ok === null && upstream.shape === "legacy" && target.appVersion !== REFERENCE_APP_VERSION) { const current = await fetchUpstreamProviders({ appVersion: REFERENCE_APP_VERSION, origin, fetchImpl, timeoutMs }); return compareUpstream({ ...target, appVersion: REFERENCE_APP_VERSION }, current).map((c) => ({ ...c, detail: `${c.detail} (as served to app ${REFERENCE_APP_VERSION}; the kit announces ${target.appVersion})` })); } diff --git a/cli/zcode-kit.mjs b/cli/zcode-kit.mjs index 9f1b69c..6dc1b54 100644 --- a/cli/zcode-kit.mjs +++ b/cli/zcode-kit.mjs @@ -149,7 +149,7 @@ function usage(code) { --select shows one numbered list instead: chosen = configured, the other detected ones = skipped) (doctor: --forget removes a stored decision (the integration stays; setup asks again); --upstream compares the kit's gateway with the provider config the ZCode client receives — network, opt-in) - (proxy status --json: one object, connection details without the key) + (proxy status --json: one object, connection details without the key; exit 0 only when this kit's proxy answers) (setup: --account-rotator y|n for unattended installs; --verbose for full installer output) (proxy start/status and setup print the connection details for manual client setup; the key is shown in full only on an interactive terminal — export: zcode-kit models --show-key) @@ -607,14 +607,15 @@ async function forgetDecision() { assertNotCheckoutWrite(); ensureState(ctx); acquireLock(BACKUP_DIR); - const tx = beginTransaction(BACKUP_DIR, `zcode-kit doctor --forget ${id}`); + let tx = null; let txId = null; let result; try { + tx = beginTransaction(BACKUP_DIR, `zcode-kit doctor --forget ${id}`); result = forgetHarnessChoice(ctx, tx, id); } finally { let finishErr = null; - try { txId = tx.finish(); } catch (err) { finishErr = err; } + try { if (tx) txId = tx.finish(); } catch (err) { finishErr = err; } releaseLock(join(BACKUP_DIR, ".setup-lock")); if (finishErr) console.error(`WARN: recording the transaction failed (${finishErr.message})`); } diff --git a/install.ps1 b/install.ps1 index 80772a5..c19343a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -177,6 +177,9 @@ $bunExe = @(Get-Command bun -CommandType Application -ErrorAction Stop)[0].Sourc # Ctrl-C during the questions; answers already given were applied. Write-Host ' [WARN] setup was interrupted; the kit is installed and the answers given so far were applied. Finish later with: zcode-kit setup' } elseif ($LASTEXITCODE -ne 0) { throw "setup failed (exit $LASTEXITCODE) - see output above" } + # Installed (possibly with a warning above): callers that check + # $LASTEXITCODE after this script must see success, not setup's 20/130. + $global:LASTEXITCODE = 0 } finally { Pop-Location } diff --git a/install.sh b/install.sh index 7d92c64..e610c84 100644 --- a/install.sh +++ b/install.sh @@ -64,8 +64,12 @@ printf '%s\n' "$VERSION" | grep -Eq "$VERSION_PATTERN" || die "invalid release v # ZCODE_KIT_MIRROR=portable forces the portable path (diagnostics, tests). mirror_portable() { src=$1; dst=$2 - (cd "$src" && tar -cf - --exclude='.bun-path' --exclude='.proxykey' --exclude='config.yaml' --exclude='node_modules' \ - --exclude='backups' --exclude='logs' --exclude='generated' .) | (cd "$dst" && tar -xf -) || return 1 + # Through a file, not a pipe: without pipefail a failed or partial archive + # step would go unnoticed and the delete phase below would still run. + (cd "$src" && tar -cf "$TMP/mirror.tar" --exclude='.bun-path' --exclude='.proxykey' --exclude='config.yaml' --exclude='node_modules' \ + --exclude='backups' --exclude='logs' --exclude='generated' .) || return 1 + (cd "$dst" && tar -xf "$TMP/mirror.tar") || return 1 + rm -f "$TMP/mirror.tar" # Delete only after the copy succeeded. Protected names are pruned, so # neither they nor anything below them is ever listed for removal. Like # rsync, a directory the release dropped goes only when it is empty after diff --git a/tests/installer-audit.test.mjs b/tests/installer-audit.test.mjs index 2ee953c..189e0cf 100644 --- a/tests/installer-audit.test.mjs +++ b/tests/installer-audit.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -223,6 +223,10 @@ printf 'setup ran\\n'`); "zcode-proxy-src/node_modules/x/index.js": "x\n", }; for (const [rel, body] of Object.entries(existing)) { mkdirSync(join(install, rel, ".."), { recursive: true }); writeFileSync(join(install, rel), body); } + // An installed file is older than the release (rsync's quick check + // compares size and mtime; same-second fixtures would look unchanged). + const old = new Date("2020-01-01T00:00:00Z"); + for (const rel of Object.keys(existing)) utimesSync(join(install, rel), old, old); const result = runSh({ bin, home, install, temp, log, extraEnv: { RELEASE_DIR: release, ...(mode === "portable" ? { ZCODE_KIT_MIRROR: "portable" } : {}) } }); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stdout, /Updating existing installation; keeping your configuration/); @@ -295,7 +299,10 @@ exit $rc const result = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", wrapper, INSTALL_PS1], { encoding: "utf8", timeout: 30_000 }); return { result, install }; } -const PS1_RELEASE = { "package.json": '{"name":"zcode-agent-kit"}', "cli/zcode-kit.mjs": "// release v2", "zcode-proxy-src/package.json": "{}" }; +// Like a real release, it ships proxy/ and zcode-proxy-src/: robocopy /MIR +// purges a whole directory the release no longer ships, protected files inside +// included (a real release never drops these two). +const PS1_RELEASE = { "package.json": '{"name":"zcode-agent-kit"}', "cli/zcode-kit.mjs": "// release v2", "proxy/config.example.yaml": "port: 1", "zcode-proxy-src/package.json": "{}" }; const pwshAvailable = () => process.platform === "win32" && spawnSync("pwsh", ["-NoProfile", "-Command", "1"], { encoding: "utf8" }).status === 0; test("install.ps1 repeat install (robocopy /MIR): local state survives, files the release dropped are removed", { skip: process.platform !== "win32" }, (t) => { diff --git a/zcode-proxy-src/src/index.ts b/zcode-proxy-src/src/index.ts index 5bb9878..9c0681c 100644 --- a/zcode-proxy-src/src/index.ts +++ b/zcode-proxy-src/src/index.ts @@ -250,7 +250,11 @@ async function serve(configPath: string | undefined, debug: boolean): Promise process.exit(0), 10_000); forceExit.unref(); - void server.close().then(() => flushAccountMetadata(auth)).then(() => process.exit(0)); + // Long streams may keep close() busy past the 10 s cap: give it 6 s, + // then the bounded (3 s) metadata write, then exit — all inside the cap. + void Promise.race([server.close(), new Promise((resolve) => setTimeout(resolve, 6_000))]) + .then(() => flushAccountMetadata(auth)) + .then(() => process.exit(0)); return true; }, }); diff --git a/zcode-proxy-src/src/proxy/handler.ts b/zcode-proxy-src/src/proxy/handler.ts index 6975bb4..a482c7b 100644 --- a/zcode-proxy-src/src/proxy/handler.ts +++ b/zcode-proxy-src/src/proxy/handler.ts @@ -751,6 +751,12 @@ export async function dispatchWithConnectRetry( if (!opts.streamPrelude || !isGatedStream(resp)) return resp; const verdict = await opts.streamPrelude(resp); resp = verdict.response; + // The client may have left while the prelude was read: nothing further + // (captcha, recovery, health bookkeeping) is done for it. + if (aborted()) { + void resp.body?.cancel().catch(() => {}); + throw new Error("client aborted during the stream prelude"); + } if (!verdict.retryable) return resp; reason = `stream ${verdict.kind}: ${verdict.reason}`; kind = "stream"; diff --git a/zcode-proxy-src/src/proxy/stream-prelude.test.ts b/zcode-proxy-src/src/proxy/stream-prelude.test.ts index a579228..076351f 100644 --- a/zcode-proxy-src/src/proxy/stream-prelude.test.ts +++ b/zcode-proxy-src/src/proxy/stream-prelude.test.ts @@ -182,6 +182,19 @@ describe("gateStreamPrelude", () => { expect(cancelled).toBe(true); }); + it("splits frames with mixed line endings exactly like the frame-end scanner (no swallowed content frame)", async () => { + const mixed = 'event: ping\ndata: {"type":"ping"}\n\r\nevent: content_block_start\r\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\r\n\r\n'; + const verdict = await gateStreamPrelude(sse([START, mixed, OVERLOADED])); + expect(verdict.kind).toBe("content"); + expect(await verdict.response.text()).toBe(START + mixed + OVERLOADED); + }); + + it("a body that cannot be decoded is handed on, never retried (deterministic, not a network failure)", async () => { + const verdict = await gateStreamPrelude(sse(["not gzip at all"], { headers: { "content-encoding": "gzip" } })); + expect(verdict.retryable).toBe(false); + expect(verdict.reason).toContain("decoded"); + }); + it("understands CRLF frames", async () => { const crlf = (s: string): string => s.replace(/\n/g, "\r\n"); const verdict = await gateStreamPrelude(sse([crlf(START), crlf(OVERLOADED)])); diff --git a/zcode-proxy-src/src/proxy/stream-prelude.ts b/zcode-proxy-src/src/proxy/stream-prelude.ts index 023e239..9e90286 100644 --- a/zcode-proxy-src/src/proxy/stream-prelude.ts +++ b/zcode-proxy-src/src/proxy/stream-prelude.ts @@ -58,9 +58,14 @@ function concatAll(parts: readonly Uint8Array[]): Uint8Array { return out; } -/** Split text that ends exactly at a frame boundary into its frames (blank-line separated). */ +/** + * Split text that ends exactly at a frame boundary into its frames. Line + * endings are normalized first so every mix lastFrameEnd accepts (CRLF, LF, + * CR, e.g. `\n\r\n`) splits the same way; only classification sees this, the + * forwarded bytes are untouched. + */ function splitFrames(text: string): string[] { - return text.split(/\r\n\r\n|\n\n|\r\r/).filter((frame) => frame.length > 0); + return text.replace(/\r\n|\r/g, "\n").split("\n\n").filter((frame) => frame.length > 0); } /** @@ -228,7 +233,12 @@ export async function gateStreamPrelude(resp: Response, opts: { timeoutMs?: numb return finish("passthrough", false, "client aborted during the prelude", undefined, { error: new Error("client aborted") }); } if (raced === TIMEOUT) return finish("passthrough", false, "prelude undecided within the time limit", inflight); - if ("failure" in raced) return finish("eof", true, "stream failed in the prelude", undefined, raced.failure); + if ("failure" in raced) { + // Decoded here and nothing decodable yet: most likely a mislabelled or + // corrupt coding — deterministic, so a retry would only cost requests. + if (codings.length && heldBytes === 0 && pending.length === 0) return finish("passthrough", false, "body could not be decoded", undefined, raced.failure); + return finish("eof", true, "stream failed in the prelude", undefined, raced.failure); + } const { done, value } = raced.read; if (done) return finish("eof", true, "stream ended in the prelude"); pending = pending.length ? concatAll([pending, value]) : value;