From f02e2f2ea56de141916a494039cc4439d8c3b631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 01:42:06 +0100 Subject: [PATCH 1/4] fix(server): align direct listener access checks --- dev-docs/REMOTE_CONTROL.md | 59 +++++++- docs/help/de/browser-sidecars.md | 4 +- docs/help/de/remote-access.md | 8 + docs/help/de/remote-control.md | 4 +- docs/help/de/security.md | 6 +- docs/help/en/browser-sidecars.md | 4 +- docs/help/en/remote-access.md | 8 + docs/help/en/remote-control.md | 4 +- docs/help/en/security.md | 6 +- docs/help/es/browser-sidecars.md | 4 +- docs/help/es/remote-access.md | 8 + docs/help/es/remote-control.md | 4 +- docs/help/es/security.md | 6 +- docs/help/fr/browser-sidecars.md | 4 +- docs/help/fr/remote-access.md | 8 + docs/help/fr/remote-control.md | 4 +- docs/help/fr/security.md | 6 +- docs/help/he/browser-sidecars.md | 4 +- docs/help/he/remote-access.md | 8 + docs/help/he/remote-control.md | 4 +- docs/help/he/security.md | 6 +- docs/help/ja/browser-sidecars.md | 4 +- docs/help/ja/remote-access.md | 8 + docs/help/ja/remote-control.md | 4 +- docs/help/ja/security.md | 6 +- docs/help/ne/browser-sidecars.md | 4 +- docs/help/ne/remote-access.md | 8 + docs/help/ne/remote-control.md | 4 +- docs/help/ne/security.md | 6 +- docs/help/ru/browser-sidecars.md | 4 +- docs/help/ru/remote-access.md | 8 + docs/help/ru/remote-control.md | 4 +- docs/help/ru/security.md | 6 +- docs/help/tr/browser-sidecars.md | 4 +- docs/help/tr/remote-access.md | 8 + docs/help/tr/remote-control.md | 4 +- docs/help/tr/security.md | 6 +- docs/help/zh-Hans/browser-sidecars.md | 4 +- docs/help/zh-Hans/remote-access.md | 8 + docs/help/zh-Hans/remote-control.md | 4 +- docs/help/zh-Hans/security.md | 6 +- packages/server/src/api-types.ts | 2 + packages/server/src/auth/manager.ts | 16 +- .../server/src/auth/session-manager.test.ts | 46 ++++++ packages/server/src/auth/session-manager.ts | 39 ++++- packages/server/src/index.ts | 5 + packages/server/src/remote-control/gate.ts | 11 +- .../src/remote-control/remote-surface.test.ts | 34 +++++ .../remote-control/test-support/surface.ts | 9 +- .../server/__tests__/cors-preflight.test.ts | 2 +- .../server/__tests__/sidecar-proxy.test.ts | 116 +++++++++++++- .../server/src/server/direct-listener.test.ts | 143 ++++++++++++++++++ packages/server/src/server/direct-origin.ts | 68 +++++++++ packages/server/src/server/http-server.ts | 126 +++++---------- .../server/routes/auth-responsiveness.test.ts | 23 +++ packages/server/src/server/routes/auth.ts | 2 + packages/server/src/server/routes/meta.ts | 3 +- .../src/server/routes/remote-control.ts | 6 +- .../src/components/folder-selection-view.tsx | 8 +- .../remote-access-settings-section.tsx | 9 +- .../settings/saved-remote-servers-card.tsx | 8 +- .../src/components/sidecar-picker-dialog.tsx | 8 +- packages/ui/src/lib/server-meta.ts | 3 + .../browser/fixtures/host-only-controls.tsx | 34 +++++ .../tests/browser/host-only-controls.test.ts | 62 ++++++++ 65 files changed, 901 insertions(+), 161 deletions(-) create mode 100644 packages/server/src/auth/session-manager.test.ts create mode 100644 packages/server/src/server/direct-listener.test.ts create mode 100644 packages/server/src/server/direct-origin.ts create mode 100644 packages/ui/tests/browser/fixtures/host-only-controls.tsx create mode 100644 packages/ui/tests/browser/host-only-controls.test.ts diff --git a/dev-docs/REMOTE_CONTROL.md b/dev-docs/REMOTE_CONTROL.md index 284bdbd3b8..8ad08b9bca 100644 --- a/dev-docs/REMOTE_CONTROL.md +++ b/dev-docs/REMOTE_CONTROL.md @@ -52,12 +52,21 @@ browser ─TLS─▶ OpenTunnel relay (forwards ciphertext) ─▶ OpenTunnel SD Every other upgrade gets 404. - `request-origin.ts` is the only authority for local privileges: `isLocalRequest()` requires a loopback peer **and** a non-ingress socket. +- The direct listener applies the shared host-only paths to every non-local + request, including encoded route targets and SideCar WebSocket upgrades. + Direct password login, its page and logout remain available to LAN clients. + `GET /api/remote-control/status` is available to authenticated clients and + returns `manageable: false` for non-local peers. SideCar registration APIs remain + available to authenticated clients; content and WebSockets require loopback. + Live preview capabilities retain their scoped admission. `/api/meta` reports + `localRequest` using that same transport authority; the UI hides password changes, + SideCar opening and remote-server management unless it is true. - `gate.ts` runs before CORS, authentication and routing. It requires the exact - tunnel `Host` and the public `Origin` on mutations, and strips forwarding + tunnel `Host` and the public `Origin` on browser mutations, and strips forwarding headers. It accepts origin-form targets only (no absolute-form, leading `//` or fragment, which the router would reinterpret) and judges the path the router routes (`server/routed-path.ts`). It denies host-only paths: login and token - bootstrap, Remote Control management, remote windows/proxy, SideCars, + bootstrap, Remote Control management, remote windows/proxy, SideCar content, previews and the automation bridge. A live `/previews//…` capability is admitted without a device cookie or public `Origin` (its sandboxed document sends neither) while the host's **Web previews** switch is on, the default: @@ -69,6 +78,9 @@ browser ─TLS─▶ OpenTunnel relay (forwards ciphertext) ─▶ OpenTunnel SD Every other path requires a paired device. Refusals close the connection, because an unread body would otherwise break the client's next keep-alive request. +- Server-to-server `POST /api/remote-control/pair` accepts a valid one-use + code with no Origin, Referer or browser fetch metadata. Browser pairing + supplies the exact tunnel Origin. Other tunnel mutations require that Origin. - A remote `/api/auth/status` without a valid device reports `pairingRequired`. The recovery dialog then explains how to pair again instead of offering password login, keeping the page and its drafts. @@ -94,6 +106,41 @@ browser ─TLS─▶ OpenTunnel relay (forwards ciphertext) ─▶ OpenTunnel SD It refuses URLs with user info or a fragment, so a pairing code or password never travels on a command line; pair from Add server instead. +## Direct listener origins and password sessions + +The direct listener grants credentialed CORS to its advertised local and remote +origins, reachable interface addresses and explicit `CODENOMAD_ALLOWED_ORIGINS`. +This environment variable is a comma-separated list of exact HTTP(S) origins, +for example `https://code.example.test,https://ui.example.test:8443`. Paths, +credentials, opaque origins and wildcards fail startup. Configure DNS aliases, +reverse-proxy public origins and separately hosted UIs here, then restart. +Forwarding headers grant neither origins nor local privileges; a proxy connected +over loopback has the privileges of its loopback transport and needs an enforcing +trusted perimeter. The configured UI development origin is admitted for local +requests. CORS for opaque preview documents is scoped to live preview capabilities. +Preview capability admission requires the matched route's `previewCapability` +configuration; a capability Host on an ordinary application route retains that +route's authentication, origin and host-only checks. + +Direct state-changing requests check Origin first, then the origin of Referer +when Origin is absent. Non-local mutations require an allowed source; native +loopback calls may omit both. Read-only methods are GET, HEAD and OPTIONS. +Authentication and the host-only gate also apply with explicitly allowed origins; +`--dangerously-skip-auth` retains the origin and host-only gates. + +SideCar WebSocket upgrades apply this same Origin/Referer policy before opening +an upstream connection, alongside their loopback transport and session checks. +Native loopback upgrades may omit both source headers. Preview WebSockets use +their scoped capability admission. + +Password sessions expire after 24 hours without an authenticated request and +after seven days from creation. `CODENOMAD_SESSION_IDLE_SECONDS` and +`CODENOMAD_SESSION_ABSOLUTE_SECONDS` configure positive integer lifetimes in +seconds at startup. Logout revokes its current token; a successful persistent +password change revokes every session for that user, including the caller. +A failed password change preserves the existing sessions. Remote Control device +credentials follow their separate registry lifetime and revocation rules. + ## Validation - `remote-surface.test.ts` drives the real `createHttpServer` through the @@ -101,6 +148,14 @@ browser ─TLS─▶ OpenTunnel relay (forwards ciphertext) ─▶ OpenTunnel SD host-only paths including encoded ones, loopback privileges, spoofed headers, upgrades, web previews and their switch, revocation of a live stream, skipped local auth and tunnel failure. +- `direct-listener.test.ts` exercises the real application with LAN and loopback + peers, credentialed CORS, Origin/Referer checks and host-only routes. + `server/__tests__/sidecar-proxy.test.ts` covers SideCar upgrade source checks, + refusal before upstream contact, authenticated and skipped-auth exchanges, + native origin-less calls and logout revocation. + `session-manager.test.ts` covers idle/absolute deadlines, configuration and + revocation; `auth-responsiveness.test.ts` covers logout and password replacement + through real auth routes alongside concurrent credential checks. - `manager.test.ts` covers resuming after a restart, Stop, never-started installations, failed resumes and backends sharing a configuration; `owner-lease.test.ts` the lease; `tests/browser/remote-control-settings.test.ts` diff --git a/docs/help/de/browser-sidecars.md b/docs/help/de/browser-sidecars.md index 2ace3b7a70..867840feb3 100644 --- a/docs/help/de/browser-sidecars.md +++ b/docs/help/de/browser-sidecars.md @@ -69,7 +69,9 @@ Registrierung ist von Clients geteilte Serverkonfiguration; sie installiert oder Prüfen Sie bei Nichterreichbarkeit Prozess und Port auf dem Backend. Lädt HTML, aber scheitern Dateien/WebSockets, prüfen Sie Basispfad und Präfix-Modus des Werkzeugs. Erstellen Sie eine falsche Registrierung mit den vorgesehenen Einstellungen neu; geben Sie den Upstream nicht nur zum Umgehen eines Proxyfehlers öffentlich frei. SideCar-Anwendungen können Terminal oder Dateien mit der Autorität des Serverkontos freigeben; vertrauen Sie ihren Benutzern und ihrer Konfiguration. -Ein SideCar ist eine vertrauenswürdige Erweiterung von CodeNomad, keine Vorschau in einer Sandbox: Seine Seite läuft unter dem eigenen Origin von CodeNomad, sodass seine Skripte CodeNomad mit Ihrem angemeldeten Zugang nutzen können. Registrieren Sie nur Dienste, deren Code Sie vertrauen. CodeNomad leitet seine eigenen Anmelde-Cookies nicht an den Dienst weiter, während die eigenen Cookies des Dienstes erhalten bleiben. Fernsteuerung stellt keinen SideCar-Anwendungsproxyverkehr bereit; verwenden Sie den passenden Direktzugriffsweg unter [Fernzugriff](remote-access.md) und [Fernsteuerung](remote-control.md). +Ein SideCar ist eine vertrauenswürdige Erweiterung von CodeNomad, keine Vorschau in einer Sandbox: Seine Seite läuft unter dem eigenen Origin von CodeNomad, sodass seine Skripte CodeNomad mit Ihrem angemeldeten Zugang nutzen können. Registrieren Sie nur Dienste, deren Code Sie vertrauen. CodeNomad leitet seine eigenen Anmelde-Cookies nicht an den Dienst weiter, während die eigenen Cookies des Dienstes erhalten bleiben. + +SideCar-Inhalte (`/sidecars/`) und SideCar-WebSockets erfordern eine Loopback-Verbindung auf dem Host. Die Registrierung über `/api/sidecars` ist für gekoppelte Geräte und authentifizierte direkte LAN-Clients verfügbar. Inhalte aktiver Vorschauen (`/previews/`) sind mit einem gültigen Berechtigungstoken über direkten LAN-Zugriff verfügbar; Web-Vorschauen der [Fernsteuerung](remote-control.md#remote-surface) bleiben mit ihrem Berechtigungstoken verfügbar, solange sie aktiviert sind. Anforderungen für den Direktzugriff stehen unter [Aktualisieren](remote-access.md#upgrading). ## OpenVSCode-Server-Rezept {#openvscode} diff --git a/docs/help/de/remote-access.md b/docs/help/de/remote-access.md index 0ebc515927..9779d104dc 100644 --- a/docs/help/de/remote-access.md +++ b/docs/help/de/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true Setzen Sie Port 9898 oder 9899 nicht voraus: Nicht angegebene Ports können bei Belegung ausweichen. Ein expliziter von null verschiedener `--https-port` oder ein `--http-port` bei aktiviertem HTTP legt den Port fest und schlägt bei fehlgeschlagener Bindung fehl; `0` fordert eine OS-Zuweisung an. Die ausgegebenen **Local Connection URL** und **Remote Connection URL** sowie aktualisierte **Erreichbare Adressen** sind für diesen Lauf maßgeblich. Siehe die vollständige [Server-CLI-Referenz](server.md). +## Aktualisieren {#upgrading} + +Melden Sie sich nach der Aktualisierung erneut an. Setzen Sie für einen Hostnamen-Alias, Reverse-Proxy oder eine separat gehostete Oberfläche `CODENOMAD_ALLOWED_ORIGINS` im Backend auf die vertrauenswürdigen Origins der Oberfläche (Schema, Hostname und Port). Native LAN-Clients müssen bei schreibenden Anfragen einen erlaubten `Origin`-Header mitsenden. + +Verwenden Sie eine Loopback-Verbindung für dem Host vorbehaltene Vorgänge: SideCar-Inhalte (`/sidecars/`) und WebSockets, Fernsteuerungsverwaltung (`/api/remote-control/`), Plugin-Brücke (`/api/opencode-plugin/`), Remote-Proxys (`/api/remote-proxy/`), Verwaltung gespeicherter Server (`/api/remote-servers/`), Passwortänderungen und Token-Ersteinrichtung. Diese Vorgänge erfordern auch bei angemeldetem direktem LAN-Zugriff Loopback. Inhalte aktiver Vorschauen (`/previews/`) sind mit einem gültigen Berechtigungstoken über direkten LAN-Zugriff verfügbar. + +Die Registrierungs-APIs `/api/sidecars` bleiben für authentifizierte direkte LAN-Clients und gekoppelte Geräte verfügbar. Web-Vorschauen der Fernsteuerung bleiben mit ihrem Berechtigungstoken verfügbar, solange sie auf dem Host aktiviert sind. + ## Vertrauenswürdiges TLS konfigurieren {#tls} ### Eigenes Zertifikat bereitstellen {#custom-certificates} diff --git a/docs/help/de/remote-control.md b/docs/help/de/remote-control.md index 0b8d1ccfd6..56488ce9b5 100644 --- a/docs/help/de/remote-control.md +++ b/docs/help/de/remote-control.md @@ -56,12 +56,12 @@ Gekoppelte Geräte können normale Projekt-/Unterhaltungs-APIs verwenden, Prompt | Passwortanmeldung/-abmeldung, Passwortänderungen, lokale Token-Ersteinrichtung | Nicht verfügbar; Gerätezugangsdaten sind auch bei deaktivierter direkter Authentifizierung nötig. | | Fernsteuerungsstatus | Für gekoppelte Geräte lesbar; Verwaltung nicht verfügbar. Start/Stop, Kopplungserstellung, Geräteliste und Widerruf bleiben auf dem Host-Zugriffsweg. | | Desktop-Remote-Fenster-/Serververwaltung und Proxy-Routen | Nicht verfügbar (`/api/remote-servers/`, `/api/remote-proxy/`). | -| SideCar-**Inhaltsproxy** | Nicht verfügbar (`/sidecars/`). Dies bedeutet **nicht**, dass alle SideCar-APIs blockiert sind: Gekoppelte Clients können weiterhin die Registrierungs-APIs `/api/sidecars` nutzen. | +| SideCar-**Inhaltsproxy** | Erfordert Loopback (`/sidecars/`), ebenso wie SideCar-WebSockets. Die Registrierungs-APIs `/api/sidecars` sind für gekoppelte Geräte und authentifizierte direkte LAN-Clients verfügbar. | | Inhalt der Web-Vorschau | Verfügbar, solange **Web-Vorschauen** (Standard) unter **Einstellungen → Remote-Zugriff → Fernsteuerung** auf dem Host eingeschaltet ist. Jede Vorschauadresse öffnet ihre Seite ohne Kopplung; halten Sie sie privat. Das Ausschalten beendet ausgelieferte Vorschauen und ihre WebSockets; gekoppelte Geräte blenden **Web-Vorschau öffnen** nach dem Neuladen aus. | | Automatisierungsbrücke | Nicht verfügbar (`/api/opencode-plugin/`). Native Desktop-Fähigkeiten werden nicht zu Browserfähigkeiten. | | WebSocket-Upgrades | Nur für Web-Vorschauen verfügbar. | -Verwenden Sie eine entsprechend geschützte [Direktverbindung](remote-access.md), wenn ein Ablauf eine blockierte Proxy-/WebSocket-Oberfläche benötigt. Fernsteuerung wechselt nicht automatisch dorthin. +Verwenden Sie eine Loopback-Verbindung auf dem Host für hostgebundene Proxy-/WebSocket-Funktionen und die Fernsteuerungsverwaltung (`/api/remote-control/`). Auch direkter LAN-Zugriff erfordert dafür Loopback; siehe [Aktualisieren](remote-access.md#upgrading). Web-Vorschauen der Fernsteuerung verwenden ein Berechtigungstoken in ihrer Adresse und bleiben verfügbar, solange sie auf dem Host aktiviert sind; halten Sie diese Adresse privat. ## Relay und Datenschutz {#relay-privacy} diff --git a/docs/help/de/security.md b/docs/help/de/security.md index 0e5d7a17b8..3a99b2eda3 100644 --- a/docs/help/de/security.md +++ b/docs/help/de/security.md @@ -31,7 +31,11 @@ Ersteinrichtung wird nur vom eigenen Loopback-Listener des Hosts angenommen, nic Direkte Anmeldung erzeugt eine undurchsichtige Backend-Sitzung im Arbeitsspeicher und ein `codenomad_session`-Cookie (oder den konfigurierten Cookie-Namen). Cookies sind `HttpOnly`, `SameSite=Lax`, auf `/` begrenzt und bei Ausgabe über HTTPS als `Secure` markiert. Es sind Browsersitzungs-Cookies; ein Backend-Neustart macht die Sitzungen im Speicher ungültig. Authentifizieren Sie sich bei Aufforderung an Ort und Stelle neu, statt fehlgeschlagene Änderungen erneut zu senden. -Ein Passwortwechsel ändert spätere Zugangsdatenprüfungen; er macht bereits ausgegebene Direkt-Sitzungen oder Fernsteuerungsgeräte **nicht** ungültig. Bei Kompromittierungsverdacht: +Abmelden widerruft die aktuelle direkte Sitzung. Ein erfolgreicher Passwortwechsel auf dem Host widerruft alle direkten Sitzungen dieses Benutzers einschließlich der aufrufenden Sitzung; melden Sie sich erneut an. Sitzungen laufen nach 24 Stunden ohne authentifizierte Anfrage oder sieben Tagen seit Erstellung ab. Passen Sie diese Grenzen vor dem Backend-Start mit `CODENOMAD_SESSION_IDLE_SECONDS` und `CODENOMAD_SESSION_ABSOLUTE_SECONDS` als positive ganze Sekunden an. Fernsteuerungsgeräte werden separat widerrufen. + +LAN-Clients können sich an- und abmelden und Projekte nutzen. Passwortänderungen, Fernsteuerungsverwaltung, Remote-Server-/Proxy-Werkzeuge und SideCars werden auf dem Loopback-Host verwaltet. Browseränderungen benötigen einen erlaubten Origin oder ersatzweise Referer. Die veröffentlichten Server-Ursprünge sind erlaubt; für DNS-Aliasse, Reverse-Proxys oder eine separat gehostete Oberfläche setzen Sie `CODENOMAD_ALLOWED_ORIGINS` auf kommagetrennte exakte HTTP(S)-Ursprünge, etwa `https://code.example.test,https://ui.example.test:8443`, und starten neu. Verwenden Sie keine Pfade oder Platzhalter. Dies gewährt Browserzugriff, keine Host-Rechte. Native LAN-Clients müssen bei Änderungen ebenfalls einen erlaubten Origin senden. Ein vertrauenswürdiger Proxy über Loopback hat Host-Rechte und benötigt eigene Zugriffskontrollen. + +Bei Kompromittierungsverdacht: 1. Begrenzen Sie die Netzwerkfreigabe und setzen Sie auf dem Host ein neues Passwort oder ändern Sie Ihr Laufzeitgeheimnis und starten neu. 2. Starten Sie das Backend neu, um vorhandene Direkt-Sitzungen ungültig zu machen. diff --git a/docs/help/en/browser-sidecars.md b/docs/help/en/browser-sidecars.md index 1cb37bfe13..67aa33548f 100644 --- a/docs/help/en/browser-sidecars.md +++ b/docs/help/en/browser-sidecars.md @@ -69,7 +69,9 @@ Registration is server configuration, shared by its clients; it neither installs If unreachable, check the process and port on the backend. If HTML loads but assets/WebSockets fail, check the tool's base path and prefix mode. Recreate a wrong registration with the intended settings; do not expose the upstream publicly just to work around a proxy error. SideCar applications can expose a terminal or files with the server account's authority; trust their users and configuration. -A SideCar is a trusted extension of CodeNomad, not a sandboxed preview: its page runs with CodeNomad's own origin, so its scripts can use CodeNomad with your signed-in access. Register only services whose code you trust. CodeNomad does not forward its own sign-in cookies to the service, while the service's own cookies are kept. Remote Control does not expose SideCar application proxy traffic; use the appropriate direct-access path described in [Remote access](remote-access.md) and [Remote Control](remote-control.md). +A SideCar is a trusted extension of CodeNomad, not a sandboxed preview: its page runs with CodeNomad's own origin, so its scripts can use CodeNomad with your signed-in access. Register only services whose code you trust. CodeNomad does not forward its own sign-in cookies to the service, while the service's own cookies are kept. + +SideCar content (`/sidecars/`) and SideCar WebSockets require a loopback connection on the host. Registration through `/api/sidecars` is available to paired devices and authenticated direct LAN clients. Live preview content (`/previews/`) is available over direct LAN access with a valid capability token; [Remote Control](remote-control.md#remote-surface) web previews remain available with their capability token while enabled. See [Upgrading](remote-access.md#upgrading) for direct-access requirements. ## OpenVSCode Server recipe {#openvscode} diff --git a/docs/help/en/remote-access.md b/docs/help/en/remote-access.md index 8dc6b30532..e0ba71f001 100644 --- a/docs/help/en/remote-access.md +++ b/docs/help/en/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true Do not assume port 9898 or 9899: omitted ports can fall back when busy. An explicit nonzero `--https-port`, or `--http-port` with HTTP enabled, pins the port and fails if it cannot bind; `0` asks the OS to assign one. The printed **Local Connection URL** and **Remote Connection URL**, and refreshed **Reachable addresses**, are authoritative for that run. See the complete [server CLI reference](server.md). +## Upgrading {#upgrading} + +Sign in again after upgrading. For a hostname alias, reverse proxy or separately hosted UI, set `CODENOMAD_ALLOWED_ORIGINS` on the backend to the trusted UI origins (scheme, hostname and port). Native LAN clients must supply an allowed `Origin` header for mutations. + +Use a loopback connection for host-only operations: SideCar content (`/sidecars/`) and WebSockets, Remote Control management (`/api/remote-control/`), the plugin bridge (`/api/opencode-plugin/`), remote proxies (`/api/remote-proxy/`), saved-server management (`/api/remote-servers/`), password changes and token bootstrap. These require loopback even when signed in through direct LAN access. Live preview content (`/previews/`) is available over direct LAN access with a valid capability token. + +The `/api/sidecars` registration APIs remain available to authenticated direct LAN clients and paired devices. Remote Control web previews remain available with their capability token while enabled on the host. + ## Configure trusted TLS {#tls} ### Supply your own certificate {#custom-certificates} diff --git a/docs/help/en/remote-control.md b/docs/help/en/remote-control.md index 0734d57604..0662cd4975 100644 --- a/docs/help/en/remote-control.md +++ b/docs/help/en/remote-control.md @@ -56,12 +56,12 @@ Paired devices can use ordinary project/conversation APIs, send prompts, inspect | Password login/logout, password changes, local token bootstrap | Unavailable; device credentials are required even if direct authentication is disabled. | | Remote Control status | Readable by a paired device; management is unavailable. Start/Stop, pairing creation, device list and revocation stay on the host access path. | | Desktop remote-window/server management and proxy routes | Unavailable (`/api/remote-servers/`, `/api/remote-proxy/`). | -| SideCar **content proxy** | Unavailable (`/sidecars/`). This does **not** mean all SideCar APIs are blocked: paired clients can still access the `/api/sidecars` registration APIs. | +| SideCar **content proxy** | Requires loopback (`/sidecars/`), as do SideCar WebSockets. The `/api/sidecars` registration APIs are available to paired devices and authenticated direct LAN clients. | | Web preview content | Available while **Web previews** is on (the default) under **Settings → Remote Access → Remote Control** on the host. Each preview address opens its page without pairing, so keep it private. Turning the switch off stops served previews and their WebSockets; paired devices hide **Open web preview** once they reload. | | Automation bridge | Unavailable (`/api/opencode-plugin/`). Native desktop capabilities do not become browser capabilities. | | WebSocket upgrades | Available for web previews only. | -Use a suitably protected [direct connection](remote-access.md) when a workflow requires a blocked proxy/WebSocket surface. Remote Control does not automatically switch to that path. +Use a loopback connection on the host for host-only proxy/WebSocket surfaces and Remote Control management (`/api/remote-control/`). Direct LAN access also requires loopback for these operations; see [Upgrading](remote-access.md#upgrading). Remote Control web previews use a capability token in the preview address and remain available while enabled on the host; keep that address private. ## Relay and privacy {#relay-privacy} diff --git a/docs/help/en/security.md b/docs/help/en/security.md index 96edc61bc7..f53573b20c 100644 --- a/docs/help/en/security.md +++ b/docs/help/en/security.md @@ -31,7 +31,11 @@ Bootstrap is accepted only from the host's own loopback listener, not from a LAN Direct sign-in creates an opaque in-memory backend session and a `codenomad_session` cookie (or the configured cookie name). Cookies are `HttpOnly`, `SameSite=Lax`, scoped to `/`, and marked `Secure` when issued over HTTPS. They are browser-session cookies; backend restart invalidates the in-memory sessions. Reauthenticate in place when asked rather than resending failed mutations. -Changing a password changes subsequent credential checks; it does **not** invalidate already issued direct sessions or Remote Control devices. For suspected compromise: +Logout revokes the current direct session. A successful password change on the host revokes all that user's direct sessions, including the caller; sign in again. Sessions expire after 24 hours without authenticated requests or seven days from creation. Set `CODENOMAD_SESSION_IDLE_SECONDS` and `CODENOMAD_SESSION_ABSOLUTE_SECONDS` to positive integer seconds before starting the backend to adjust these limits. Remote Control devices are revoked separately. + +LAN clients can sign in, sign out and use projects. Password changes, Remote Control management, remote-server/proxy tools and SideCars are managed on the loopback host. Browser mutations require an allowed Origin, or Referer when Origin is absent. The server's advertised origins are allowed; for DNS aliases, reverse proxies or a separately hosted UI, set `CODENOMAD_ALLOWED_ORIGINS` to comma-separated exact HTTP(S) origins, such as `https://code.example.test,https://ui.example.test:8443`, and restart. Use no paths or wildcards. This grants browser access, not host privileges. Native clients making LAN mutations must also supply an allowed Origin. A trusted proxy connected over loopback has host privileges and needs its own access controls. + +For suspected compromise: 1. Restrict network exposure and set a new password on the host, or change your runtime secret and restart. 2. Restart the backend to invalidate its existing direct sessions. diff --git a/docs/help/es/browser-sidecars.md b/docs/help/es/browser-sidecars.md index c654c4aec4..c2eedced70 100644 --- a/docs/help/es/browser-sidecars.md +++ b/docs/help/es/browser-sidecars.md @@ -69,7 +69,9 @@ El registro es configuración del servidor, compartida por sus clientes; no inst Si no es accesible, comprueba el proceso y el puerto en el backend. Si carga el HTML pero fallan los recursos/WebSockets, comprueba la ruta base y el modo de prefijo de la herramienta. Recrea un registro incorrecto con los ajustes previstos; no expongas públicamente el destino solo para eludir un error de proxy. Las aplicaciones SideCar pueden exponer una terminal o archivos con la autoridad de la cuenta del servidor; confía en sus usuarios y configuración. -Un SideCar es una extensión de confianza de CodeNomad, no una vista previa aislada: su página se ejecuta con el propio origen de CodeNomad, por lo que sus scripts pueden usar CodeNomad con tu acceso de sesión iniciada. Registra solo servicios cuyo código sea de tu confianza. CodeNomad no reenvía al servicio sus propias cookies de inicio de sesión, mientras que las cookies propias del servicio se conservan. Control remoto no expone el tráfico proxy de aplicaciones SideCar; usa la vía adecuada de acceso directo descrita en [Acceso remoto](remote-access.md) y [Control remoto](remote-control.md). +Un SideCar es una extensión de confianza de CodeNomad, no una vista previa aislada: su página se ejecuta con el propio origen de CodeNomad, por lo que sus scripts pueden usar CodeNomad con tu acceso de sesión iniciada. Registra solo servicios cuyo código sea de tu confianza. CodeNomad no reenvía al servicio sus propias cookies de inicio de sesión, mientras que las cookies propias del servicio se conservan. + +El contenido de SideCar (`/sidecars/`) y sus WebSockets requieren una conexión loopback en el anfitrión. El registro mediante `/api/sidecars` está disponible para dispositivos emparejados y clientes directos de LAN autenticados. El contenido de las vistas previas activas (`/previews/`) está disponible mediante acceso directo por LAN con un token de capacidad válido; las vistas previas web de [Control remoto](remote-control.md#remote-surface) siguen disponibles con su token de capacidad mientras estén activadas. Consulta [Actualizar](remote-access.md#upgrading) para los requisitos del acceso directo. ## Ejemplo de OpenVSCode Server {#openvscode} diff --git a/docs/help/es/remote-access.md b/docs/help/es/remote-access.md index c0c4dc829d..ed01c52826 100644 --- a/docs/help/es/remote-access.md +++ b/docs/help/es/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true No supongas que el puerto es 9898 o 9899: los puertos omitidos pueden cambiar si están ocupados. Un `--https-port` explícito distinto de cero, o un `--http-port` con HTTP activado, fija el puerto y falla si no puede escuchar; `0` pide al sistema operativo que asigne uno. Las **Local Connection URL** y **Remote Connection URL** indicadas, y las **Direcciones accesibles** actualizadas, son autoritativas para esa ejecución. Consulta la [referencia completa de la CLI del servidor](server.md). +## Actualizar {#upgrading} + +Vuelve a iniciar sesión después de actualizar. Para un alias de nombre de host, un proxy inverso o una interfaz alojada por separado, configura `CODENOMAD_ALLOWED_ORIGINS` en el backend con los orígenes de confianza de la interfaz (esquema, nombre de host y puerto). Los clientes nativos de la LAN deben enviar una cabecera `Origin` permitida en las solicitudes de modificación. + +Usa una conexión loopback para las operaciones reservadas al anfitrión: contenido de SideCar (`/sidecars/`) y WebSockets, gestión de Control remoto (`/api/remote-control/`), puente de plugins (`/api/opencode-plugin/`), proxies remotos (`/api/remote-proxy/`), gestión de servidores guardados (`/api/remote-servers/`), cambios de contraseña e inicialización por token. Estas operaciones requieren loopback incluso con una sesión autenticada mediante acceso directo por LAN. El contenido de las vistas previas activas (`/previews/`) está disponible mediante acceso directo por LAN con un token de capacidad válido. + +Las API de registro `/api/sidecars` siguen disponibles para los clientes directos de LAN autenticados y los dispositivos emparejados. Las vistas previas web de Control remoto siguen disponibles con su token de capacidad mientras estén activadas en el anfitrión. + ## Configurar TLS de confianza {#tls} ### Proporcionar tu propio certificado {#custom-certificates} diff --git a/docs/help/es/remote-control.md b/docs/help/es/remote-control.md index cfc70fb462..989f3756e6 100644 --- a/docs/help/es/remote-control.md +++ b/docs/help/es/remote-control.md @@ -56,12 +56,12 @@ Los dispositivos emparejados pueden usar las API normales de proyectos/conversac | Inicio/cierre de sesión con contraseña, cambios de contraseña, inicialización local por token | No disponible; se requieren credenciales de dispositivo incluso si la autenticación directa está desactivada. | | Estado de Control remoto | Legible para un dispositivo emparejado; la gestión no está disponible. Iniciar/Detener, crear emparejamientos, la lista de dispositivos y la revocación permanecen en la vía de acceso del anfitrión. | | Gestión de ventanas remotas/servidores de escritorio y rutas proxy | No disponible (`/api/remote-servers/`, `/api/remote-proxy/`). | -| **Proxy de contenido** de SideCar | No disponible (`/sidecars/`). Esto **no** significa que todas las API de SideCar estén bloqueadas: los clientes emparejados pueden seguir accediendo a las API de registro `/api/sidecars`. | +| **Proxy de contenido** de SideCar | Requiere loopback (`/sidecars/`), al igual que los WebSockets de SideCar. Las API de registro `/api/sidecars` están disponibles para dispositivos emparejados y clientes directos de LAN autenticados. | | Contenido de vistas previas web | Disponible mientras **Vistas previas web** esté activado (valor predeterminado) en **Configuración → Acceso remoto → Control remoto** en el equipo anfitrión. Cada dirección de vista previa abre su página sin emparejamiento, así que mantenla privada. Desactivar el interruptor detiene las vistas previas servidas y sus WebSockets; los dispositivos emparejados ocultan **Abrir vista previa web** al recargar. | | Puente de automatización | No disponible (`/api/opencode-plugin/`). Las capacidades nativas de escritorio no se convierten en capacidades del navegador. | | Actualizaciones de conexión a WebSocket | Disponibles solo para vistas previas web. | -Usa una [conexión directa](remote-access.md) debidamente protegida cuando un flujo requiera una superficie proxy/WebSocket bloqueada. Control remoto no cambia automáticamente a esa vía. +Usa una conexión loopback en el anfitrión para los proxies/WebSockets reservados al anfitrión y la gestión de Control remoto (`/api/remote-control/`). El acceso directo por LAN también requiere loopback para estas operaciones; consulta [Actualizar](remote-access.md#upgrading). Las vistas previas web de Control remoto usan un token de capacidad en su dirección y siguen disponibles mientras estén activadas en el anfitrión; mantén privada esa dirección. ## Relé y privacidad {#relay-privacy} diff --git a/docs/help/es/security.md b/docs/help/es/security.md index 21def004f8..f27947676b 100644 --- a/docs/help/es/security.md +++ b/docs/help/es/security.md @@ -31,7 +31,11 @@ La inicialización se acepta solo desde el listener loopback del propio anfitri El inicio de sesión directo crea una sesión opaca en memoria del backend y una cookie `codenomad_session` (o el nombre de cookie configurado). Las cookies son `HttpOnly`, `SameSite=Lax`, tienen ámbito `/` y se marcan `Secure` al emitirse por HTTPS. Son cookies de sesión del navegador; reiniciar el backend invalida las sesiones en memoria. Vuelve a autenticarte sin abandonar la vista cuando se solicite, en lugar de reenviar modificaciones fallidas. -Cambiar una contraseña cambia las comprobaciones de credenciales posteriores; **no** invalida las sesiones directas ya emitidas ni los dispositivos de Control remoto. Si sospechas una intrusión: +Cerrar sesión revoca la sesión directa actual. Cambiar correctamente la contraseña en el anfitrión revoca todas las sesiones directas de ese usuario, incluida la actual; vuelve a iniciar sesión. Las sesiones caducan tras 24 horas sin solicitudes autenticadas o siete días desde su creación. Ajusta estos límites con `CODENOMAD_SESSION_IDLE_SECONDS` y `CODENOMAD_SESSION_ABSOLUTE_SECONDS` en segundos enteros positivos antes de iniciar el backend. Los dispositivos de Control remoto se revocan por separado. + +Los clientes LAN pueden iniciar y cerrar sesión y usar proyectos. Los cambios de contraseña, la gestión de Control remoto, las herramientas de servidores remotos/proxy y los SideCars se administran en el anfitrión por loopback. Las modificaciones del navegador requieren un Origin permitido o, si falta, Referer. Se permiten los orígenes anunciados del servidor; para alias DNS, proxies inversos o una interfaz alojada aparte, configura `CODENOMAD_ALLOWED_ORIGINS` con orígenes HTTP(S) exactos separados por comas, como `https://code.example.test,https://ui.example.test:8443`, y reinicia. No uses rutas ni comodines. Esto concede acceso al navegador, no privilegios del anfitrión. Los clientes nativos también deben enviar un Origin permitido para cambios LAN. Un proxy de confianza conectado por loopback tiene privilegios del anfitrión y necesita sus propios controles de acceso. + +Si sospechas una intrusión: 1. Restringe la exposición de red y establece una contraseña nueva en el anfitrión, o cambia el secreto de ejecución y reinicia. 2. Reinicia el backend para invalidar sus sesiones directas existentes. diff --git a/docs/help/fr/browser-sidecars.md b/docs/help/fr/browser-sidecars.md index aebcb65d86..4e55c964f8 100644 --- a/docs/help/fr/browser-sidecars.md +++ b/docs/help/fr/browser-sidecars.md @@ -69,7 +69,9 @@ L’enregistrement est une configuration serveur partagée par ses clients ; il Si le service est inaccessible, vérifiez le processus et le port sur le backend. Si HTML charge mais pas les ressources/WebSockets, vérifiez le chemin de base et le mode de préfixe. Recréez un enregistrement erroné avec les paramètres voulus ; n’exposez pas l’amont publiquement pour contourner une erreur de proxy. Les applications SideCar peuvent exposer un terminal ou des fichiers avec l’autorité du compte serveur ; faites confiance à leurs utilisateurs et configuration. -Un SideCar est une extension de confiance de CodeNomad, pas un aperçu en bac à sable : sa page s’exécute avec l’origine même de CodeNomad, si bien que ses scripts peuvent utiliser CodeNomad avec votre accès connecté. N’enregistrez que des services dont vous jugez le code fiable. CodeNomad ne transmet pas ses propres cookies de connexion au service, tandis que les cookies propres au service sont conservés. Le Contrôle à distance n’expose pas le trafic proxy des applications SideCar ; utilisez le chemin direct approprié décrit dans [Accès distant](remote-access.md) et [Contrôle à distance](remote-control.md). +Un SideCar est une extension de confiance de CodeNomad, pas un aperçu en bac à sable : sa page s’exécute avec l’origine même de CodeNomad, si bien que ses scripts peuvent utiliser CodeNomad avec votre accès connecté. N’enregistrez que des services dont vous jugez le code fiable. CodeNomad ne transmet pas ses propres cookies de connexion au service, tandis que les cookies propres au service sont conservés. + +Le contenu SideCar (`/sidecars/`) et les WebSockets SideCar exigent une connexion en boucle locale sur l’hôte. L’enregistrement via `/api/sidecars` est accessible aux appareils appairés et aux clients LAN directs authentifiés. Le contenu des aperçus actifs (`/previews/`) est accessible directement depuis le LAN avec un jeton de capacité valide ; les aperçus web du [Contrôle à distance](remote-control.md#remote-surface) restent accessibles avec leur jeton de capacité lorsqu’ils sont activés. Voir [Mise à niveau](remote-access.md#upgrading) pour les conditions de l’accès direct. ## Recette OpenVSCode Server {#openvscode} diff --git a/docs/help/fr/remote-access.md b/docs/help/fr/remote-access.md index 75304cf7ea..df32a8bad8 100644 --- a/docs/help/fr/remote-access.md +++ b/docs/help/fr/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true Ne supposez pas le port 9898 ou 9899 : les ports omis peuvent changer s’ils sont occupés. Un `--https-port` explicite non nul, ou un `--http-port` avec HTTP activé, fixe le port et échoue si l’écoute est impossible ; `0` demande au système d’en attribuer un. Les **Local Connection URL** et **Remote Connection URL** affichées, et les **Adresses accessibles** actualisées, font autorité pour cette exécution. Voir la [référence CLI serveur](server.md) complète. +## Mise à niveau {#upgrading} + +Reconnectez-vous après la mise à niveau. Pour un alias de nom d’hôte, un proxy inverse ou une interface hébergée séparément, définissez `CODENOMAD_ALLOWED_ORIGINS` sur le backend avec les origines de confiance de l’interface (protocole, nom d’hôte et port). Les clients natifs du LAN doivent fournir un en-tête `Origin` autorisé pour les requêtes de modification. + +Utilisez une connexion en boucle locale pour les opérations réservées à l’hôte : contenu SideCar (`/sidecars/`) et WebSockets, gestion du Contrôle à distance (`/api/remote-control/`), pont des plugins (`/api/opencode-plugin/`), proxys distants (`/api/remote-proxy/`), gestion des serveurs enregistrés (`/api/remote-servers/`), changement de mot de passe et initialisation par jeton. Ces opérations exigent la boucle locale, même avec une connexion authentifiée directe depuis le LAN. Le contenu des aperçus actifs (`/previews/`) est accessible directement depuis le LAN avec un jeton de capacité valide. + +Les API d’enregistrement `/api/sidecars` restent accessibles aux clients LAN directs authentifiés et aux appareils appairés. Les aperçus web du Contrôle à distance restent accessibles avec leur jeton de capacité lorsqu’ils sont activés sur l’hôte. + ## Configurer un TLS de confiance {#tls} ### Fournir son propre certificat {#custom-certificates} diff --git a/docs/help/fr/remote-control.md b/docs/help/fr/remote-control.md index cb1f559923..05fff29438 100644 --- a/docs/help/fr/remote-control.md +++ b/docs/help/fr/remote-control.md @@ -56,12 +56,12 @@ Les appareils appairés peuvent utiliser les API ordinaires de projet/conversati | Connexion/déconnexion par mot de passe, changements de mot de passe, initialisation locale par jeton | Indisponibles ; les identifiants d’appareil sont requis même si l’authentification directe est désactivée. | | État du Contrôle à distance | Lisible par un appareil appairé ; la gestion est indisponible. Démarrage/arrêt, création d’appairage, liste et révocation des appareils restent sur le chemin d’accès de l’hôte. | | Gestion des fenêtres distantes/serveurs de bureau et routes proxy | Indisponibles (`/api/remote-servers/`, `/api/remote-proxy/`). | -| **Proxy de contenu** SideCar | Indisponible (`/sidecars/`). Cela ne signifie **pas** que toutes les API SideCar sont bloquées : les clients appairés peuvent toujours accéder aux API d’enregistrement `/api/sidecars`. | +| **Proxy de contenu** SideCar | Exige la boucle locale (`/sidecars/`), comme les WebSockets SideCar. Les API d’enregistrement `/api/sidecars` sont accessibles aux appareils appairés et aux clients LAN directs authentifiés. | | Contenu des aperçus web | Disponible tant que **Aperçus web** est activé (par défaut) dans **Paramètres → Accès distant → Contrôle à distance** sur l’hôte. Chaque adresse d’aperçu ouvre sa page sans appairage : gardez-la privée. Désactiver l’interrupteur arrête les aperçus servis et leurs WebSockets ; les appareils appairés masquent **Ouvrir l'aperçu web** une fois rechargés. | | Pont d’automatisation | Indisponible (`/api/opencode-plugin/`). Les capacités natives de bureau ne deviennent pas des capacités navigateur. | | Mises à niveau WebSocket | Disponibles pour les aperçus web uniquement. | -Utilisez une [connexion directe](remote-access.md) convenablement protégée si un parcours nécessite une surface proxy/WebSocket bloquée. Le Contrôle à distance ne bascule pas automatiquement vers ce chemin. +Utilisez une connexion en boucle locale sur l’hôte pour les proxys/WebSockets réservés à l’hôte et la gestion du Contrôle à distance (`/api/remote-control/`). L’accès LAN direct exige lui aussi la boucle locale pour ces opérations ; voir [Mise à niveau](remote-access.md#upgrading). Les aperçus web du Contrôle à distance utilisent un jeton de capacité dans leur adresse et restent disponibles lorsqu’ils sont activés sur l’hôte ; gardez cette adresse privée. ## Relais et confidentialité {#relay-privacy} diff --git a/docs/help/fr/security.md b/docs/help/fr/security.md index e4beed5856..007335241a 100644 --- a/docs/help/fr/security.md +++ b/docs/help/fr/security.md @@ -31,7 +31,11 @@ L’initialisation n’est acceptée que depuis l’écoute de boucle locale de La connexion directe crée une session opaque en mémoire du backend et un cookie `codenomad_session` (ou le nom configuré). Les cookies sont `HttpOnly`, `SameSite=Lax`, limités à `/` et marqués `Secure` s’ils sont émis en HTTPS. Ce sont des cookies de session navigateur ; le redémarrage du backend invalide les sessions en mémoire. Réauthentifiez-vous sur place si demandé plutôt que de renvoyer les modifications échouées. -Changer un mot de passe change les vérifications futures ; cela n’invalide **pas** les sessions directes déjà émises ni les appareils de Contrôle à distance. En cas de compromission suspectée : +La déconnexion révoque la session directe courante. Un changement de mot de passe réussi sur l’hôte révoque toutes les sessions directes de cet utilisateur, y compris celle de l’appelant ; reconnectez-vous. Les sessions expirent après 24 heures sans requête authentifiée ou sept jours depuis leur création. Pour ajuster ces limites, définissez `CODENOMAD_SESSION_IDLE_SECONDS` et `CODENOMAD_SESSION_ABSOLUTE_SECONDS` en secondes entières positives avant de démarrer le backend. Les appareils de Contrôle à distance se révoquent séparément. + +Les clients LAN peuvent se connecter, se déconnecter et utiliser les projets. Le mot de passe, le Contrôle à distance, les outils de serveurs distants/proxy et les SideCars se gèrent sur l’hôte en loopback. Les modifications du navigateur exigent une Origin autorisée, ou le Referer en son absence. Les origines annoncées du serveur sont autorisées ; pour les alias DNS, proxys inverses ou interfaces hébergées séparément, définissez `CODENOMAD_ALLOWED_ORIGINS` avec des origines HTTP(S) exactes séparées par des virgules, par exemple `https://code.example.test,https://ui.example.test:8443`, puis redémarrez. N’ajoutez ni chemin ni joker. Cela autorise le navigateur, sans donner les privilèges de l’hôte. Les clients natifs effectuant des modifications LAN doivent aussi fournir une Origin autorisée. Un proxy de confiance connecté en loopback dispose des privilèges de l’hôte et doit appliquer ses propres contrôles d’accès. + +En cas de compromission suspectée : 1. Restreignez l’exposition réseau et définissez un nouveau mot de passe sur l’hôte, ou changez votre secret d’exécution et redémarrez. 2. Redémarrez le backend pour invalider ses sessions directes existantes. diff --git a/docs/help/he/browser-sidecars.md b/docs/help/he/browser-sidecars.md index a680568a66..78e2ac077b 100644 --- a/docs/help/he/browser-sidecars.md +++ b/docs/help/he/browser-sidecars.md @@ -69,7 +69,9 @@ אם אינו נגיש, בדקו את התהליך והיציאה בשרת הקצה. אם HTML נטען אך נכסים/WebSockets נכשלים, בדקו את נתיב הבסיס ומצב הקידומת של הכלי. צרו מחדש רישום שגוי עם ההגדרות הרצויות; אל תחשפו את השירות שמאחוריו לציבור רק כדי לעקוף שגיאת מתווך. יישומי SideCar יכולים לחשוף מסוף או קבצים בסמכות חשבון השרת; תנו אמון במשתמשים ובתצורה שלהם. -SideCar הוא הרחבה מהימנה של CodeNomad, לא תצוגה מקדימה בארגז חול: הדף שלו רץ במקור (origin) של CodeNomad עצמו, ולכן הסקריפטים שלו יכולים להשתמש ב־CodeNomad עם הגישה שלכם כמשתמשים מחוברים. רשמו רק שירותים שאתם סומכים על הקוד שלהם. CodeNomad אינו מעביר לשירות את עוגיות ההתחברות שלו, ואילו העוגיות של השירות עצמו נשמרות. שליטה מרחוק אינה חושפת תעבורת מתווך של יישומי SideCar; השתמשו בנתיב הגישה הישירה המתאים המתואר ב־[גישה מרחוק](remote-access.md) וב־[שליטה מרחוק](remote-control.md). +SideCar הוא הרחבה מהימנה של CodeNomad, לא תצוגה מקדימה בארגז חול: הדף שלו רץ במקור (origin) של CodeNomad עצמו, ולכן הסקריפטים שלו יכולים להשתמש ב־CodeNomad עם הגישה שלכם כמשתמשים מחוברים. רשמו רק שירותים שאתם סומכים על הקוד שלהם. CodeNomad אינו מעביר לשירות את עוגיות ההתחברות שלו, ואילו העוגיות של השירות עצמו נשמרות. + +תוכן SideCar ‏(`/sidecars/`) וחיבורי WebSocket של SideCar דורשים חיבור loopback במארח. רישום דרך `/api/sidecars` זמין למכשירים מצומדים וללקוחות LAN ישירים מאומתים. תוכן תצוגות מקדימות פעילות (`/previews/`) זמין בגישת LAN ישירה עם אסימון יכולת תקף; תצוגות האינטרנט של [שליטה מרחוק](remote-control.md#remote-surface) נשארות זמינות עם אסימון היכולת שלהן כל עוד הן מופעלות. לדרישות הגישה הישירה ראו [שדרוג](remote-access.md#upgrading). ## מתכון ל־OpenVSCode Server {#openvscode} diff --git a/docs/help/he/remote-access.md b/docs/help/he/remote-access.md index 589f5cae6a..64c935e63c 100644 --- a/docs/help/he/remote-access.md +++ b/docs/help/he/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true אל תניחו יציאה 9898 או 9899: יציאות שלא צוינו יכולות להתחלף כשהן תפוסות. `--https-port` מפורש שאינו אפס, או `--http-port` כש־HTTP מופעל, מקבעים את היציאה ונכשלים אם אי אפשר להאזין בה; `0` מבקש ממערכת ההפעלה להקצות אחת. **Local Connection URL** ו־**Remote Connection URL** המודפסות, ו־**כתובות נגישות** לאחר רענון, הן הסמכות לאותה הרצה. ראו [מדריך CLI המלא לשרת](server.md). +## שדרוג {#upgrading} + +התחברו מחדש לאחר השדרוג. עבור כינוי לשם מארח, מתווך הפוך או ממשק שמתארח בנפרד, הגדירו בשרת הקצה את `CODENOMAD_ALLOWED_ORIGINS` למקורות המהימנים של הממשק (סכמה, שם מארח ויציאה). לקוחות LAN מקוריים חייבים לספק כותרת `Origin` מותרת בבקשות לשינוי נתונים. + +השתמשו בחיבור loopback לפעולות השמורות למארח: תוכן SideCar ‏(`/sidecars/`) ו־WebSockets, ניהול שליטה מרחוק (`/api/remote-control/`), גשר התוספים (`/api/opencode-plugin/`), מתווכים מרוחקים (`/api/remote-proxy/`), ניהול שרתים שמורים (`/api/remote-servers/`), שינוי סיסמה ואתחול באמצעות אסימון. פעולות אלה דורשות loopback גם לאחר התחברות מאומתת בגישה ישירה דרך LAN. תוכן תצוגות מקדימות פעילות (`/previews/`) זמין בגישת LAN ישירה עם אסימון יכולת תקף. + +ממשקי הרישום `/api/sidecars` נשארים זמינים ללקוחות LAN ישירים מאומתים ולמכשירים מצומדים. תצוגות האינטרנט של שליטה מרחוק נשארות זמינות עם אסימון היכולת שלהן כל עוד הן מופעלות במארח. + ## הגדרת TLS מהימן {#tls} ### אספקת תעודה משלכם {#custom-certificates} diff --git a/docs/help/he/remote-control.md b/docs/help/he/remote-control.md index 39294addee..785bdee58e 100644 --- a/docs/help/he/remote-control.md +++ b/docs/help/he/remote-control.md @@ -56,12 +56,12 @@ | התחברות/יציאה בסיסמה, שינויי סיסמה, אתחול אסימון מקומי | לא זמינים; נדרשים אישורי מכשיר גם אם אימות ישיר מושבת. | | מצב שליטה מרחוק | ניתן לקריאה בידי מכשיר מצומד; הניהול אינו זמין. הפעלה/עצירה, יצירת צימוד, רשימת מכשירים וביטול גישה נשארים בנתיב הגישה של המארח. | | ניהול חלונות מרוחקים/שרתים בשולחן העבודה ונתיבי מתווך | לא זמינים (`/api/remote-servers/`, `/api/remote-proxy/`). | -| **מתווך תוכן** של SideCar | לא זמין (`/sidecars/`). הדבר **אינו** אומר שכל ממשקי SideCar חסומים: לקוחות מצומדים עדיין יכולים לגשת לממשקי הרישום `/api/sidecars`. | +| **מתווך תוכן** של SideCar | דורש loopback ‏(`/sidecars/`), וכך גם חיבורי WebSocket של SideCar. ממשקי הרישום `/api/sidecars` זמינים למכשירים מצומדים וללקוחות LAN ישירים מאומתים. | | תוכן תצוגה מקדימה של אתר | זמין כל עוד **תצוגות מקדימות של אתרים** פעיל (ברירת המחדל) תחת **הגדרות → גישה מרוחקת → שליטה מרחוק** במארח. כל כתובת של תצוגה מקדימה פותחת את הדף שלה ללא צימוד, ולכן שמרו עליה פרטית. כיבוי המתג עוצר את התצוגות המקדימות המוגשות ואת חיבורי ה־WebSocket שלהן; מכשירים מצומדים מסתירים את **פתח תצוגה מקדימה של אתר** לאחר טעינה מחדש. | | גשר אוטומציה | לא זמין (`/api/opencode-plugin/`). יכולות שולחן עבודה מקוריות אינן הופכות ליכולות דפדפן. | | שדרוגי WebSocket | זמינים רק לתצוגות מקדימות של אתרים. | -השתמשו ב־[חיבור ישיר](remote-access.md) מוגן כראוי כאשר תהליך עבודה מחייב ממשק מתווך/WebSocket חסום. שליטה מרחוק אינה עוברת אוטומטית לנתיב הזה. +השתמשו בחיבור loopback במארח עבור מתווכים וחיבורי WebSocket השמורים למארח ועבור ניהול שליטה מרחוק (`/api/remote-control/`). גם בגישת LAN ישירה פעולות אלה דורשות loopback; ראו [שדרוג](remote-access.md#upgrading). תצוגות האינטרנט של שליטה מרחוק משתמשות באסימון יכולת בכתובת שלהן ונשארות זמינות כל עוד הן מופעלות במארח; שמרו את הכתובת פרטית. ## ממסר ופרטיות {#relay-privacy} diff --git a/docs/help/he/security.md b/docs/help/he/security.md index 5aa882d7bf..da6a32bbca 100644 --- a/docs/help/he/security.md +++ b/docs/help/he/security.md @@ -31,7 +31,11 @@ התחברות ישירה יוצרת סשן אטום בזיכרון שרת הקצה ועוגיית `codenomad_session` (או בשם העוגייה שהוגדר). העוגיות הן `HttpOnly`, `SameSite=Lax`, תחומות ל־`/`, ומסומנות `Secure` כשמונפקות ב־HTTPS. אלו עוגיות סשן דפדפן; הפעלה מחדש של שרת הקצה מבטלת את הסשנים בזיכרון. התחברו מחדש במקום כשמתבקשים, במקום לשלוח שוב פעולות שינוי שנכשלו. -שינוי סיסמה משנה בדיקות אישורים עתידיות; הוא **אינו** מבטל סשנים ישירים או מכשירי שליטה מרחוק שכבר אושרו. בחשד לפריצה: +התנתקות מבטלת את הסשן הישיר הנוכחי. שינוי סיסמה מוצלח במארח מבטל את כל הסשנים הישירים של המשתמש, כולל הסשן הנוכחי; התחברו מחדש. סשנים פגים לאחר 24 שעות ללא בקשה מאומתת או שבעה ימים מיצירתם. התאימו את הגבולות באמצעות `CODENOMAD_SESSION_IDLE_SECONDS` ו־`CODENOMAD_SESSION_ABSOLUTE_SECONDS` בשניות שלמות וחיוביות לפני הפעלת שרת הקצה. מכשירי שליטה מרחוק מבוטלים בנפרד. + +לקוחות LAN יכולים להתחבר, להתנתק ולהשתמש בפרויקטים. שינוי סיסמה, ניהול שליטה מרחוק, כלי שרתים מרוחקים ו־proxy ו־SideCars מנוהלים במארח דרך loopback. שינויי דפדפן דורשים Origin מורשה, או Referer בהיעדרו. מקורות השרת המפורסמים מורשים; לכינויי DNS, שרתי proxy הפוכים או ממשק המאוחסן בנפרד, הגדירו `CODENOMAD_ALLOWED_ORIGINS` כמקורות HTTP(S) מדויקים המופרדים בפסיקים, למשל `https://code.example.test,https://ui.example.test:8443`, והפעילו מחדש. אין להשתמש בנתיבים או בתווים כלליים. ההרשאה מעניקה גישת דפדפן, לא הרשאות מארח. גם לקוחות מקוריים צריכים לשלוח Origin מורשה לשינויי LAN. ל־proxy מהימן המתחבר דרך loopback יש הרשאות מארח והוא זקוק לבקרת גישה משלו. + +בחשד לפריצה: 1. הגבילו חשיפה לרשת והגדירו סיסמה חדשה במארח, או שנו את סוד זמן הריצה והפעילו מחדש. 2. הפעילו מחדש את שרת הקצה כדי לבטל את הסשנים הישירים הקיימים שלו. diff --git a/docs/help/ja/browser-sidecars.md b/docs/help/ja/browser-sidecars.md index 453ad5d548..df1e207c99 100644 --- a/docs/help/ja/browser-sidecars.md +++ b/docs/help/ja/browser-sidecars.md @@ -69,7 +69,9 @@ Electron と Windows Tauri では、モバイルテンプレートは、所有 到達できない場合はバックエンドのプロセスとポートを確認します。HTML は読めても資産/WebSocket が失敗する場合は、ツールのベースパスとプレフィックスモードを確認します。誤った登録は目的の設定で作り直してください。プロキシエラーの回避だけのために上流を公開しないでください。SideCar アプリは、サーバーアカウントの権限でターミナルやファイルを公開できるため、利用者と設定を信頼する必要があります。 -SideCar はサンドボックス化されたプレビューではなく、CodeNomad の信頼された拡張です。そのページは CodeNomad 自身のオリジンで動作するため、スクリプトはサインイン済みのアクセス権で CodeNomad を利用できます。コードを信頼できるサービスだけを登録してください。CodeNomad は自身のサインイン Cookie をサービスへ転送しませんが、サービス自身の Cookie は保持されます。リモートコントロールは SideCar アプリのプロキシ通信を公開しません。[リモートアクセス](remote-access.md)と[リモートコントロール](remote-control.md)に記載の適切な直接接続経路を使用します。 +SideCar はサンドボックス化されたプレビューではなく、CodeNomad の信頼された拡張です。そのページは CodeNomad 自身のオリジンで動作するため、スクリプトはサインイン済みのアクセス権で CodeNomad を利用できます。コードを信頼できるサービスだけを登録してください。CodeNomad は自身のサインイン Cookie をサービスへ転送しませんが、サービス自身の Cookie は保持されます。 + +SideCar のコンテンツ(`/sidecars/`)と WebSocket には、ホスト上のループバック接続が必要です。`/api/sidecars` による登録は、ペアリング済み端末と認証済みの LAN 直接接続クライアントで利用できます。有効なケイパビリティトークンがあれば、アクティブなプレビューのコンテンツ(`/previews/`)は LAN からの直接アクセスで利用できます。[リモートコントロール](remote-control.md#remote-surface)の Web プレビューは、有効な間、ケイパビリティトークンで利用できます。直接アクセスの要件は[アップグレード](remote-access.md#upgrading)を参照してください。 ## OpenVSCode Server の例 {#openvscode} diff --git a/docs/help/ja/remote-access.md b/docs/help/ja/remote-access.md index abc13919b6..da374da1c5 100644 --- a/docs/help/ja/remote-access.md +++ b/docs/help/ja/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true ポートを 9898 や 9899 と決めつけないでください。省略時は使用中なら別ポートにフォールバックできます。ゼロ以外の `--https-port`、または HTTP 有効時の `--http-port` を明示するとポートを固定し、バインドできなければ失敗します。`0` は OS の割り当てを要求します。その実行での正規の情報は、表示された **Local Connection URL** と **Remote Connection URL**、および更新した **到達可能なアドレス** です。完全な[サーバー CLI リファレンス](server.md)を参照してください。 +## アップグレード {#upgrading} + +アップグレード後は再度ログインしてください。ホスト名の別名、リバースプロキシ、別途ホストする UI を使う場合は、バックエンドの `CODENOMAD_ALLOWED_ORIGINS` に信頼する UI のオリジン(スキーム、ホスト名、ポート)を設定します。LAN 上のネイティブクライアントは、変更リクエストに許可された `Origin` ヘッダーを付ける必要があります。 + +ホスト専用の操作にはループバック接続を使ってください。対象は SideCar のコンテンツ(`/sidecars/`)と WebSocket、リモートコントロールの管理(`/api/remote-control/`)、プラグインブリッジ(`/api/opencode-plugin/`)、リモートプロキシ(`/api/remote-proxy/`)、保存済みサーバーの管理(`/api/remote-servers/`)、パスワード変更、トークンによる初期設定です。LAN からの直接アクセスでログイン済みでも、これらにはループバックが必要です。有効なケイパビリティトークンがあれば、アクティブなプレビューのコンテンツ(`/previews/`)は LAN からの直接アクセスで利用できます。 + +`/api/sidecars` 登録 API は、認証済みの LAN 直接接続クライアントとペアリング済み端末で引き続き利用できます。リモートコントロールの Web プレビューは、ホストで有効な間、ケイパビリティトークンで利用できます。 + ## 信頼された TLS を設定する {#tls} ### 独自の証明書を指定する {#custom-certificates} diff --git a/docs/help/ja/remote-control.md b/docs/help/ja/remote-control.md index eb985f80c9..f5aa0e33c4 100644 --- a/docs/help/ja/remote-control.md +++ b/docs/help/ja/remote-control.md @@ -56,12 +56,12 @@ | パスワードログイン/ログアウト、パスワード変更、ローカルトークン初期設定 | 利用不可。直接接続の認証が無効でも、端末認証情報が必要です。 | | リモートコントロールの状態 | ペアリング済み端末で読み取り可能ですが管理は不可。開始/停止、ペアリング作成、端末一覧、取り消しはホストのアクセス経路に限ります。 | | デスクトップのリモートウィンドウ/サーバー管理とプロキシルート | 利用不可(`/api/remote-servers/`、`/api/remote-proxy/`)。 | -| SideCar の **コンテンツプロキシ** | 利用不可(`/sidecars/`)。すべての SideCar API がブロックされるという意味 **ではありません**。ペアリング済みクライアントは `/api/sidecars` 登録 API にアクセスできます。 | +| SideCar の **コンテンツプロキシ** | ループバックが必要(`/sidecars/`)。SideCar の WebSocket も同様です。`/api/sidecars` 登録 API は、ペアリング済み端末と認証済みの LAN 直接接続クライアントで利用できます。 | | Web プレビューの内容 | ホストの **設定 → リモートアクセス → リモートコントロール** で **Web プレビュー** がオン(既定)の間は利用可能。各プレビューのアドレスはペアリングなしでそのページを開くため、他人に知られないようにしてください。スイッチをオフにすると、提供中のプレビューとその WebSocket が停止し、ペアリング済み端末では再読み込み後に **Web プレビューを開く** が表示されなくなります。 | | 自動操作ブリッジ | 利用不可(`/api/opencode-plugin/`)。ネイティブデスクトップの能力がブラウザーの能力になるわけではありません。 | | WebSocket アップグレード | Web プレビューでのみ利用可能。 | -ブロックされたプロキシ/WebSocket が必要な作業では、適切に保護された[直接接続](remote-access.md)を使用します。リモートコントロールは自動的にその経路へ切り替わりません。 +ホスト専用のプロキシ/WebSocket とリモートコントロールの管理(`/api/remote-control/`)には、ホスト上のループバック接続を使います。LAN からの直接アクセスでも、これらの操作にはループバックが必要です。[アップグレード](remote-access.md#upgrading)を参照してください。リモートコントロールの Web プレビューは、アドレスに含まれるケイパビリティトークンを使い、ホストで有効な間は利用できます。そのアドレスは他人に知られないようにしてください。 ## リレーとプライバシー {#relay-privacy} diff --git a/docs/help/ja/security.md b/docs/help/ja/security.md index af7dd1f1f5..080d490d5c 100644 --- a/docs/help/ja/security.md +++ b/docs/help/ja/security.md @@ -31,7 +31,11 @@ CodeNomad へのアクセスにより、OpenCode を通じてバックエンド 直接ログインは、バックエンドのメモリ内に不透明なセッションを作成し、`codenomad_session` Cookie(または設定した Cookie 名)を発行します。Cookie は `HttpOnly`、`SameSite=Lax`、範囲 `/` で、HTTPS 発行時は `Secure` です。ブラウザーセッション Cookie であり、バックエンドの再起動でメモリ内セッションは無効になります。要求されたら、その場で再認証し、失敗した変更操作を再送しないでください。 -パスワード変更は、以後の認証情報チェックを変えますが、発行済みの直接セッションやリモートコントロール端末を無効化 **しません**。侵害が疑われる場合: +ログアウトすると現在の直接セッションが失効します。ホストでパスワード変更が成功すると、そのユーザーのすべての直接セッション(現在のセッションを含む)が失効します。再ログインしてください。セッションは認証済みリクエストがない状態で24時間、または作成から7日で期限切れになります。変更する場合はバックエンド起動前に `CODENOMAD_SESSION_IDLE_SECONDS` と `CODENOMAD_SESSION_ABSOLUTE_SECONDS` を正の整数秒で設定してください。リモートコントロール端末は別途取り消します。 + +LAN クライアントはログイン、ログアウト、プロジェクトの利用ができます。パスワード変更、リモートコントロール管理、リモートサーバー/プロキシツール、SideCars はホストのループバック接続から管理します。ブラウザーの変更操作には許可された Origin、または Origin がない場合は Referer が必要です。サーバーが公開するオリジンは許可されます。DNS 別名、リバースプロキシ、別ホストの UI には `CODENOMAD_ALLOWED_ORIGINS` に `https://code.example.test,https://ui.example.test:8443` のような正確な HTTP(S) オリジンをカンマ区切りで設定し、再起動します。パスやワイルドカードは使えません。これはブラウザーアクセスを許可するもので、ホスト権限は付与しません。ネイティブ LAN クライアントも変更時に許可された Origin を送信します。ループバック接続の信頼されたプロキシはホスト権限を持つため、独自のアクセス制御が必要です。 + +侵害が疑われる場合: 1. ネットワーク公開を制限し、ホストで新しいパスワードを設定するか、実行時の秘密情報を変更して再起動します。 2. バックエンドを再起動して、既存の直接セッションを無効化します。 diff --git a/docs/help/ne/browser-sidecars.md b/docs/help/ne/browser-sidecars.md index e14a1edbec..4664539de9 100644 --- a/docs/help/ne/browser-sidecars.md +++ b/docs/help/ne/browser-sidecars.md @@ -69,7 +69,9 @@ Electron र Windows Tauri मा mobile template ले स्वामित पहुँच नभए backend मा process र port जाँच्नुहोस्। HTML खुले पनि asset/WebSocket असफल भए tool को base path र prefix mode जाँच्नुहोस्। गलत दर्ता चाहेको सेटिङमा फेरि बनाउनुहोस्; proxy त्रुटि छल्न upstream सार्वजनिक नगर्नुहोस्। SideCar अनुप्रयोगले server खाताको अधिकारमा terminal वा फाइल देखाउन सक्छन्; प्रयोगकर्ता र कन्फिगरेसनलाई विश्वास गर्नुपर्छ। -SideCar sandbox गरिएको पूर्वावलोकन होइन, CodeNomad को विश्वसनीय विस्तार हो: यसको पृष्ठ CodeNomad कै origin मा चल्छ, त्यसैले यसका script ले तपाईंको sign-in भएको पहुँचसहित CodeNomad प्रयोग गर्न सक्छन्। जसको code मा विश्वास छ ती सेवा मात्र दर्ता गर्नुहोस्। CodeNomad ले आफ्नै sign-in cookie सेवामा पठाउँदैन, तर सेवाका आफ्नै cookie राखिन्छन्। Remote Control ले SideCar application proxy traffic दिँदैन; [रिमोट पहुँच](remote-access.md) र [Remote Control](remote-control.md) मा भएको उपयुक्त direct-access मार्ग प्रयोग गर्नुहोस्। +SideCar sandbox गरिएको पूर्वावलोकन होइन, CodeNomad को विश्वसनीय विस्तार हो: यसको पृष्ठ CodeNomad कै origin मा चल्छ, त्यसैले यसका script ले तपाईंको sign-in भएको पहुँचसहित CodeNomad प्रयोग गर्न सक्छन्। जसको code मा विश्वास छ ती सेवा मात्र दर्ता गर्नुहोस्। CodeNomad ले आफ्नै sign-in cookie सेवामा पठाउँदैन, तर सेवाका आफ्नै cookie राखिन्छन्। + +SideCar सामग्री (`/sidecars/`) र SideCar WebSocket लाई होस्टमा loopback जडान चाहिन्छ। `/api/sidecars` मार्फत दर्ता जोडी बनाइएका उपकरण र प्रमाणीकृत प्रत्यक्ष LAN क्लाइन्टलाई उपलब्ध छ। सक्रिय पूर्वावलोकन सामग्री (`/previews/`) वैध क्षमता टोकनसहित प्रत्यक्ष LAN पहुँचमा उपलब्ध छ; [Remote Control](remote-control.md#remote-surface) का वेब पूर्वावलोकन सक्षम रहँदा आफ्नो क्षमता टोकनसहित उपलब्ध रहन्छन्। प्रत्यक्ष पहुँचका आवश्यकताका लागि [स्तरोन्नति](remote-access.md#upgrading) हेर्नुहोस्। ## OpenVSCode Server विधि {#openvscode} diff --git a/docs/help/ne/remote-access.md b/docs/help/ne/remote-access.md index 5dce2174de..1fd3814162 100644 --- a/docs/help/ne/remote-access.md +++ b/docs/help/ne/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true 9898 वा 9899 port नमान्नुहोस्: नदिएका port व्यस्त भए अर्कोमा जान सक्छन्। स्पष्ट nonzero `--https-port`, वा HTTP सक्षम हुँदा `--http-port`, ले port स्थिर गरी bind नसके असफल हुन्छ; `0` ले OS लाई चयन गर्न दिन्छ। छापिएका **Local Connection URL**, **Remote Connection URL** र ताजा **पहुँचयोग्य ठेगानाहरू** त्यस run का आधिकारिक हुन्। पूर्ण [server CLI सन्दर्भ](server.md) हेर्नुहोस्। +## स्तरोन्नति {#upgrading} + +स्तरोन्नतिपछि फेरि साइन इन गर्नुहोस्। होस्टनामको उपनाम, रिभर्स प्रोक्सी वा छुट्टै होस्ट गरिएको इन्टरफेसका लागि backend मा `CODENOMAD_ALLOWED_ORIGINS` लाई इन्टरफेसका विश्वसनीय origin (स्किम, होस्टनाम र पोर्ट) मा सेट गर्नुहोस्। नेटिभ LAN क्लाइन्टले डेटा परिवर्तन गर्ने अनुरोधमा अनुमति दिइएको `Origin` हेडर पठाउनुपर्छ। + +होस्टमा सीमित कार्यका लागि loopback जडान प्रयोग गर्नुहोस्: SideCar सामग्री (`/sidecars/`) र WebSocket, Remote Control व्यवस्थापन (`/api/remote-control/`), प्लगइन ब्रिज (`/api/opencode-plugin/`), रिमोट प्रोक्सी (`/api/remote-proxy/`), सुरक्षित सर्भर व्यवस्थापन (`/api/remote-servers/`), पासवर्ड परिवर्तन र टोकनबाट प्रारम्भिक सेटअप। प्रत्यक्ष LAN पहुँचबाट साइन इन गरे पनि यी कार्यलाई loopback चाहिन्छ। सक्रिय पूर्वावलोकन सामग्री (`/previews/`) वैध क्षमता टोकनसहित प्रत्यक्ष LAN पहुँचमा उपलब्ध छ। + +`/api/sidecars` दर्ता API प्रमाणीकृत प्रत्यक्ष LAN क्लाइन्ट र जोडी बनाइएका उपकरणलाई उपलब्ध रहन्छन्। होस्टमा सक्षम रहँदा Remote Control का वेब पूर्वावलोकन आफ्नो क्षमता टोकनसहित उपलब्ध रहन्छन्। + ## Trusted TLS कन्फिगर {#tls} ### आफ्नै certificate दिनुहोस् {#custom-certificates} diff --git a/docs/help/ne/remote-control.md b/docs/help/ne/remote-control.md index f362ab9a8f..4b0eb91dc6 100644 --- a/docs/help/ne/remote-control.md +++ b/docs/help/ne/remote-control.md @@ -56,12 +56,12 @@ paired device ले समान ownership जाँचअन्तर्गत | password login/logout, password परिवर्तन, local token bootstrap | अनुपलब्ध; direct authentication disabled भए पनि device credential चाहिन्छ। | | Remote Control status | paired device ले पढ्न सक्छ; management अनुपलब्ध। Start/Stop, pairing सिर्जना, device सूची र revocation host access path मै रहन्छन्। | | desktop remote-window/server management र proxy route | अनुपलब्ध (`/api/remote-servers/`, `/api/remote-proxy/`)। | -| SideCar **सामग्री proxy** | अनुपलब्ध (`/sidecars/`)। यसको अर्थ सबै SideCar API blocked **होइन**: paired client ले `/api/sidecars` registration API अझै प्रयोग गर्न सक्छन्। | +| SideCar **सामग्री proxy** | loopback चाहिन्छ (`/sidecars/`); SideCar WebSocket लाई पनि यही चाहिन्छ। `/api/sidecars` दर्ता API जोडी बनाइएका उपकरण र प्रमाणीकृत प्रत्यक्ष LAN क्लाइन्टलाई उपलब्ध छन्। | | वेब पूर्वावलोकन सामग्री | host मा **सेटिङहरू → रिमोट पहुँच → रिमोट कन्ट्रोल** अन्तर्गत **वेब पूर्वावलोकन** खुला (पूर्वनिर्धारित) रहँदा उपलब्ध। प्रत्येक पूर्वावलोकन ठेगानाले जोडाइबिना आफ्नो पृष्ठ खोल्छ, त्यसैले यसलाई निजी राख्नुहोस्। switch बन्द गर्दा दिइएका पूर्वावलोकन र तिनका WebSocket रोकिन्छन्; जोडिएका उपकरणहरूले reload पछि **वेब पूर्वावलोकन खोल्नुहोस्** लुकाउँछन्। | | automation bridge | अनुपलब्ध (`/api/opencode-plugin/`)। native desktop क्षमता browser क्षमता बन्दैनन्। | | WebSocket upgrade | वेब पूर्वावलोकनका लागि मात्र उपलब्ध। | -blocked proxy/WebSocket चाहिने प्रक्रियामा उचित सुरक्षित [प्रत्यक्ष जडान](remote-access.md) प्रयोग गर्नुहोस्। Remote Control त्यसमा स्वतः switch हुँदैन। +होस्टमा सीमित प्रोक्सी/WebSocket र Remote Control व्यवस्थापन (`/api/remote-control/`) का लागि होस्टमा loopback जडान प्रयोग गर्नुहोस्। प्रत्यक्ष LAN पहुँचमा पनि यी कार्यलाई loopback चाहिन्छ; [स्तरोन्नति](remote-access.md#upgrading) हेर्नुहोस्। Remote Control का वेब पूर्वावलोकनले ठेगानामा भएको क्षमता टोकन प्रयोग गर्छन् र होस्टमा सक्षम रहँदा उपलब्ध रहन्छन्; त्यो ठेगाना निजी राख्नुहोस्। ## Relay र गोपनीयता {#relay-privacy} diff --git a/docs/help/ne/security.md b/docs/help/ne/security.md index d77ce14e0b..41cd6972a6 100644 --- a/docs/help/ne/security.md +++ b/docs/help/ne/security.md @@ -31,7 +31,11 @@ bootstrap host कै loopback listener बाट मात्र स्वी direct sign-in ले opaque in-memory backend session र `codenomad_session` cookie (वा configured नाम) बनाउँछ। cookie `HttpOnly`, `SameSite=Lax`, `/` दायराका र HTTPS बाट जारी हुँदा `Secure` हुन्छन्। browser-session cookie हुन्; backend restart ले in-memory session अमान्य गर्छ। माग्दा असफल परिवर्तन फेरि पठाउनुको सट्टा त्यहीं reauthenticate गर्नुहोस्। -password बदल्दा पछिका credential जाँच बदलिन्छन्; जारी भइसकेका direct session वा Remote Control device अमान्य **हुँदैनन्**। compromise शङ्का भए: +logout ले हालको direct session रद्द गर्छ। host मा password सफलतापूर्वक बदल्दा त्यस प्रयोगकर्ताका सबै direct session, हालको समेत, रद्द हुन्छन्; फेरि sign-in गर्नुहोस्। authenticated request नभएको 24 घण्टा वा सिर्जनादेखि सात दिनमा session म्याद सकिन्छ। सीमा बदल्न backend सुरु गर्नुअघि `CODENOMAD_SESSION_IDLE_SECONDS` र `CODENOMAD_SESSION_ABSOLUTE_SECONDS` धनात्मक पूर्णाङ्क सेकेन्डमा सेट गर्नुहोस्। Remote Control device छुट्टै रद्द गरिन्छन्। + +LAN client ले sign-in, logout र project प्रयोग गर्न सक्छन्। password परिवर्तन, Remote Control व्यवस्थापन, remote-server/proxy tool र SideCars host को loopback बाट व्यवस्थापन गरिन्छन्। browser परिवर्तनमा अनुमति भएको Origin, वा त्यो नभए Referer चाहिन्छ। server का प्रकाशित origin अनुमति हुन्छन्; DNS alias, reverse proxy वा अलग होस्ट गरिएको UI का लागि `CODENOMAD_ALLOWED_ORIGINS` मा ठ्याक्कै HTTP(S) origin अल्पविरामले छुट्याएर, जस्तै `https://code.example.test,https://ui.example.test:8443`, सेट गरी restart गर्नुहोस्। path वा wildcard नराख्नुहोस्। यसले browser पहुँच दिन्छ, host अधिकार होइन। native LAN client ले पनि परिवर्तन गर्दा अनुमति भएको Origin पठाउनुपर्छ। loopback बाट जोडिएको विश्वसनीय proxy सँग host अधिकार हुन्छ र आफ्नै पहुँच नियन्त्रण चाहिन्छ। + +compromise शङ्का भए: 1. network exposure सीमित गरी host मा नयाँ password सेट, वा runtime secret बदलेर restart गर्नुहोस्। 2. विद्यमान direct session अमान्य गर्न backend restart गर्नुहोस्। diff --git a/docs/help/ru/browser-sidecars.md b/docs/help/ru/browser-sidecars.md index 7588b2343f..b1afd15c04 100644 --- a/docs/help/ru/browser-sidecars.md +++ b/docs/help/ru/browser-sidecars.md @@ -69,7 +69,9 @@ При недоступности проверьте процесс и порт на серверной части. Если HTML загружается, но ресурсы/WebSockets нет, проверьте базовый путь и режим префикса. Пересоздайте неверную регистрацию с нужными настройками; не открывайте исходную службу публично для обхода ошибки прокси. Приложения SideCar могут предоставлять терминал или файлы с правами серверной учётной записи; доверяйте их пользователям и конфигурации. -SideCar — доверенное расширение CodeNomad, а не изолированный предпросмотр: его страница работает в собственном источнике (origin) CodeNomad, поэтому её скрипты могут пользоваться CodeNomad с вашим выполненным входом. Регистрируйте только службы, коду которых доверяете. CodeNomad не передаёт службе собственные cookies входа, а cookies самой службы сохраняются. Удалённое управление не предоставляет прокси-трафик приложений SideCar; используйте подходящий прямой путь из разделов [Удалённый доступ](remote-access.md) и [Удалённое управление](remote-control.md). +SideCar — доверенное расширение CodeNomad, а не изолированный предпросмотр: его страница работает в собственном источнике (origin) CodeNomad, поэтому её скрипты могут пользоваться CodeNomad с вашим выполненным входом. Регистрируйте только службы, коду которых доверяете. CodeNomad не передаёт службе собственные cookies входа, а cookies самой службы сохраняются. + +Содержимое SideCar (`/sidecars/`) и WebSocket SideCar требуют loopback-подключения на хосте. Регистрация через `/api/sidecars` доступна сопряжённым устройствам и аутентифицированным прямым LAN-клиентам. Содержимое активных предпросмотров (`/previews/`) доступно через прямой LAN-доступ с действительным токеном доступа; веб-предпросмотры [удалённого управления](remote-control.md#remote-surface) остаются доступны с токеном доступа, пока включены. Требования прямого доступа см. в разделе [Обновление](remote-access.md#upgrading). ## Пример OpenVSCode Server {#openvscode} diff --git a/docs/help/ru/remote-access.md b/docs/help/ru/remote-access.md index 1188844a07..0075c0efd7 100644 --- a/docs/help/ru/remote-access.md +++ b/docs/help/ru/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true Не рассчитывайте на порт 9898 или 9899: незаданные порты могут заменяться при занятости. Явный ненулевой `--https-port`, либо `--http-port` при включённом HTTP, фиксирует порт и даёт ошибку при невозможности привязки; `0` просит ОС назначить порт. Напечатанные **Local Connection URL** и **Remote Connection URL**, а также обновлённые **Доступные адреса** достоверны для данного запуска. См. полный [справочник CLI сервера](server.md). +## Обновление {#upgrading} + +После обновления войдите снова. Для псевдонима хоста, обратного прокси или отдельно размещённого интерфейса задайте в серверной части `CODENOMAD_ALLOWED_ORIGINS` с доверенными источниками интерфейса (схема, имя хоста и порт). Нативные LAN-клиенты должны передавать разрешённый заголовок `Origin` в запросах изменения данных. + +Используйте loopback-подключение для операций, доступных только с хоста: содержимое SideCar (`/sidecars/`) и WebSocket, управление удалённым доступом через туннель (`/api/remote-control/`), мост плагинов (`/api/opencode-plugin/`), удалённые прокси (`/api/remote-proxy/`), управление сохранёнными серверами (`/api/remote-servers/`), изменение пароля и начальная настройка токеном. Для этих операций требуется loopback даже после входа через прямой LAN-доступ. Содержимое активных предпросмотров (`/previews/`) доступно через прямой LAN-доступ с действительным токеном доступа. + +API регистрации `/api/sidecars` остаются доступны аутентифицированным прямым LAN-клиентам и сопряжённым устройствам. Веб-предпросмотры удалённого управления остаются доступны с токеном доступа, пока они включены на хосте. + ## Настройка доверенного TLS {#tls} ### Собственный сертификат {#custom-certificates} diff --git a/docs/help/ru/remote-control.md b/docs/help/ru/remote-control.md index 336e52af5e..a7eb8f0dc1 100644 --- a/docs/help/ru/remote-control.md +++ b/docs/help/ru/remote-control.md @@ -56,12 +56,12 @@ | Вход/выход по паролю, изменение пароля, локальная начальная настройка токеном | Недоступны; учётные данные устройства обязательны даже при отключённой прямой аутентификации. | | Статус удалённого управления | Читается сопряжённым устройством; управление недоступно. Запуск/остановка, создание сопряжения, список устройств и отзыв остаются на пути доступа хоста. | | Управление настольными удалёнными окнами/серверами и маршруты прокси | Недоступны (`/api/remote-servers/`, `/api/remote-proxy/`). | -| **Прокси содержимого** SideCar | Недоступен (`/sidecars/`). Это **не** означает блокировку всех API SideCar: сопряжённые клиенты всё ещё имеют доступ к API регистрации `/api/sidecars`. | +| **Прокси содержимого** SideCar | Требует loopback (`/sidecars/`), как и WebSocket SideCar. API регистрации `/api/sidecars` доступны сопряжённым устройствам и аутентифицированным прямым LAN-клиентам. | | Содержимое веб-предпросмотра | Доступно, пока на хосте включён **Веб-предпросмотр** (по умолчанию) в **Настройки → Удалённый доступ → Удалённое управление**. Адрес каждого предпросмотра открывает его страницу без сопряжения, поэтому держите его в тайне. Выключение переключателя останавливает обслуживаемые предпросмотры и их WebSocket; сопряжённые устройства скрывают **Открыть веб-предпросмотр** после перезагрузки страницы. | | Мост автоматизации | Недоступен (`/api/opencode-plugin/`). Нативные возможности рабочего стола не становятся возможностями браузера. | | Обновления соединения до WebSocket | Доступны только для веб-предпросмотра. | -Используйте должным образом защищённое [прямое подключение](remote-access.md), если нужны заблокированные поверхности прокси/WebSocket. Удалённое управление автоматически на этот путь не переключается. +Используйте loopback-подключение на хосте для прокси/WebSocket, доступных только с хоста, и управления туннелем (`/api/remote-control/`). При прямом LAN-доступе для этих операций также требуется loopback; см. [Обновление](remote-access.md#upgrading). Веб-предпросмотры удалённого управления используют токен доступа в адресе и остаются доступны, пока включены на хосте; держите этот адрес в тайне. ## Ретранслятор и конфиденциальность {#relay-privacy} diff --git a/docs/help/ru/security.md b/docs/help/ru/security.md index 7e1b97316f..dc66aceff9 100644 --- a/docs/help/ru/security.md +++ b/docs/help/ru/security.md @@ -31,7 +31,11 @@ Прямой вход создаёт непрозрачную сессию в памяти серверной части и cookie `codenomad_session` (или настроенное имя). Cookie имеют `HttpOnly`, `SameSite=Lax`, область `/` и `Secure` при выдаче через HTTPS. Это сессионные cookies браузера; перезапуск серверной части делает сессии в памяти недействительными. При запросе войдите заново на месте, а не повторяйте неудачные изменения. -Изменение пароля меняет последующие проверки данных; **не** аннулирует уже выданные прямые сессии или устройства удалённого управления. При подозрении на компрометацию: +Выход отзывает текущую прямую сессию. Успешная смена пароля на хосте отзывает все прямые сессии пользователя, включая текущую; войдите заново. Сессии истекают после 24 часов без аутентифицированных запросов или семи дней с создания. Для изменения сроков задайте `CODENOMAD_SESSION_IDLE_SECONDS` и `CODENOMAD_SESSION_ABSOLUTE_SECONDS` в положительных целых секундах до запуска серверной части. Устройства удалённого управления отзываются отдельно. + +Клиенты LAN могут входить, выходить и работать с проектами. Смена пароля, управление удалённым управлением, инструменты удалённых серверов/прокси и SideCars доступны на хосте через loopback. Изменения из браузера требуют разрешённый Origin либо Referer при его отсутствии. Объявленные источники сервера разрешены; для псевдонимов DNS, обратных прокси или отдельно размещённого интерфейса задайте `CODENOMAD_ALLOWED_ORIGINS` как точные HTTP(S)-источники через запятую, например `https://code.example.test,https://ui.example.test:8443`, и перезапустите. Пути и маски не допускаются. Это даёт браузерный доступ, а не права хоста. Нативные LAN-клиенты также должны передавать разрешённый Origin при изменениях. Доверенный прокси через loopback имеет права хоста и нуждается в собственном контроле доступа. + +При подозрении на компрометацию: 1. Ограничьте сетевую доступность и задайте новый пароль на хосте либо измените пароль, передаваемый при запуске, и перезапустите. 2. Перезапустите серверную часть для аннулирования существующих прямых сессий. diff --git a/docs/help/tr/browser-sidecars.md b/docs/help/tr/browser-sidecars.md index 124d103af9..5e869e7d11 100644 --- a/docs/help/tr/browser-sidecars.md +++ b/docs/help/tr/browser-sidecars.md @@ -69,7 +69,9 @@ Kayıt, istemcilerin paylaştığı sunucu yapılandırmasıdır; hizmeti yükle Ulaşılamıyorsa arka uçtaki işlemi ve bağlantı noktasını kontrol edin. HTML yüklenir ancak asset/WebSockets başarısız olursa, aracın temel yolunu ve önek modunu kontrol edin. Yanlış bir kaydı amaçlanan ayarlarla yeniden oluşturun; Proxy hatasını geçici olarak çözmek için yukarı akışı herkese açık şekilde göstermeyin. SideCar uygulamaları, sunucu hesabının yetkisine sahip bir terminali veya dosyaları açığa çıkarabilir; kullanıcılarına ve yapılandırmalarına güvenin. -SideCar, korumalı alana alınmış bir önizleme değil, CodeNomad'ın güvenilir bir uzantısıdır: sayfası CodeNomad'ın kendi kaynağıyla (origin) çalışır, bu nedenle betikleri oturum açmış erişiminizle CodeNomad'ı kullanabilir. Yalnızca koduna güvendiğiniz hizmetleri kaydedin. CodeNomad kendi oturum açma çerezlerini hizmete iletmez; hizmetin kendi çerezleri ise korunur. Uzaktan Kontrol, SideCar uygulama proxy trafiğini açığa çıkarmaz; [Uzaktan erişim](remote-access.md) ve [Uzaktan Kontrol](remote-control.md)'de açıklanan uygun doğrudan erişim yolunu kullanın. +SideCar, korumalı alana alınmış bir önizleme değil, CodeNomad'ın güvenilir bir uzantısıdır: sayfası CodeNomad'ın kendi kaynağıyla (origin) çalışır, bu nedenle betikleri oturum açmış erişiminizle CodeNomad'ı kullanabilir. Yalnızca koduna güvendiğiniz hizmetleri kaydedin. CodeNomad kendi oturum açma çerezlerini hizmete iletmez; hizmetin kendi çerezleri ise korunur. + +SideCar içeriği (`/sidecars/`) ve SideCar WebSocket'leri, ana bilgisayarda loopback bağlantısı gerektirir. `/api/sidecars` üzerinden kayıt, eşleştirilmiş cihazlar ve kimliği doğrulanmış doğrudan LAN istemcileri için kullanılabilir. Etkin önizleme içeriği (`/previews/`), geçerli bir yetenek belirteciyle doğrudan LAN erişiminde kullanılabilir; [Uzaktan Kontrol](remote-control.md#remote-surface) web önizlemeleri etkin oldukları sürece yetenek belirteçleriyle kullanılabilir kalır. Doğrudan erişim gereksinimleri için bkz. [Yükseltme](remote-access.md#upgrading). ## OpenVSCode Sunucu tarifi {#openvscode} diff --git a/docs/help/tr/remote-access.md b/docs/help/tr/remote-access.md index 0a8464c58e..ac548447f7 100644 --- a/docs/help/tr/remote-access.md +++ b/docs/help/tr/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true 9898 veya 9899 portunu varsaymayın: belirtilmeyen portlar doluyken başka port kullanılabilir. Açıkça verilen sıfır olmayan `--https-port` veya HTTP etkinken `--http-port` portu sabitler ve bağlanamazsa başarısız olur; `0` işletim sisteminden port atamasını ister. Yazdırılan **Local Connection URL** ve **Remote Connection URL** ile yenilenen **Erişilebilir adresler** o çalıştırma için yetkili bilgidir. Tam [sunucu CLI başvurusuna](server.md) bakın. +## Yükseltme {#upgrading} + +Yükseltmeden sonra yeniden oturum açın. Ana bilgisayar adı takma adı, ters proxy veya ayrı barındırılan arayüz için arka uçta `CODENOMAD_ALLOWED_ORIGINS` değerini arayüzün güvenilir origin adreslerine (şema, ana bilgisayar adı ve port) ayarlayın. Yerel LAN istemcileri, değişiklik isteklerinde izin verilen bir `Origin` başlığı göndermelidir. + +Ana bilgisayara özel işlemler için loopback bağlantısı kullanın: SideCar içeriği (`/sidecars/`) ve WebSocket'ler, Uzaktan Kontrol yönetimi (`/api/remote-control/`), eklenti köprüsü (`/api/opencode-plugin/`), uzak proxy'ler (`/api/remote-proxy/`), kayıtlı sunucu yönetimi (`/api/remote-servers/`), parola değişikliği ve belirteçle ilk kurulum. Doğrudan LAN erişimiyle oturum açıldığında da bu işlemler loopback gerektirir. Etkin önizleme içeriği (`/previews/`), geçerli bir yetenek belirteciyle doğrudan LAN erişiminde kullanılabilir. + +`/api/sidecars` kayıt API'leri, kimliği doğrulanmış doğrudan LAN istemcileri ve eşleştirilmiş cihazlar için kullanılabilir kalır. Uzaktan Kontrol web önizlemeleri, ana bilgisayarda etkin oldukları sürece yetenek belirteçleriyle kullanılabilir. + ## Güvenilir TLS'yi yapılandırın {#tls} ### Kendi sertifikanızı sağlayın {#custom-certificates} diff --git a/docs/help/tr/remote-control.md b/docs/help/tr/remote-control.md index 7823f86e36..7582cf945f 100644 --- a/docs/help/tr/remote-control.md +++ b/docs/help/tr/remote-control.md @@ -56,12 +56,12 @@ Eşleştirilen cihazlar sıradan proje/konuşma API'lerini kullanabilir, istemle | Parolayla giriş/çıkış, parola değişiklikleri, yerel belirteç önyüklemesi | Mevcut değil; doğrudan kimlik doğrulama devre dışı bırakılsa bile cihaz kimlik bilgileri gereklidir. | | Uzaktan Kontrol durumu | Eşleştirilmiş bir cihaz tarafından okunabilir; yönetim mevcut değildir. Başlat/Durdur, eşleştirme oluşturma, cihaz listesi ve iptal etme, ana bilgisayar erişim yolunda kalır. | | Masaüstü uzak pencere/sunucu yönetimi ve proxy rotaları | Kullanılamıyor (`/api/remote-servers/`, `/api/remote-proxy/`). | -| SideCar **içerik proxy'si** | Kullanılamaz (`/sidecars/`). Bu, tüm SideCar API'lerinin engellendiği anlamına **gelmez**: eşleştirilmiş istemciler yine de `/api/sidecars` kayıt API'lerine erişebilir. | +| SideCar **içerik proxy'si** | SideCar WebSocket'leri gibi loopback gerektirir (`/sidecars/`). `/api/sidecars` kayıt API'leri, eşleştirilmiş cihazlar ve kimliği doğrulanmış doğrudan LAN istemcileri için kullanılabilir. | | Web önizleme içeriği | Ana bilgisayarda **Ayarlar → Uzak Erişim → Uzaktan Kontrol** altında **Web önizlemeleri** açık olduğu sürece (varsayılan) kullanılabilir. Her önizleme adresi sayfasını eşleştirme olmadan açar; bu yüzden gizli tutun. Anahtarı kapatmak sunulan önizlemeleri ve WebSocket bağlantılarını durdurur; eşleştirilmiş cihazlar yeniden yüklendiğinde **Web önizlemesini aç** seçeneğini gizler. | | Otomasyon köprüsü | Kullanılamaz (`/api/opencode-plugin/`). Native masaüstü yetenekleri tarayıcı yeteneğine dönüşmez. | | WebSocket yükseltmeleri | Yalnızca web önizlemeleri için kullanılabilir. | -Bir iş akışı engellenmiş bir proxy/WebSocket yüzeyi gerektirdiğinde uygun şekilde korunan bir [doğrudan bağlantı](remote-access.md) kullanın. Uzaktan Kontrol otomatik olarak bu yola geçmez. +Ana bilgisayara özel proxy/WebSocket yüzeyleri ve Uzaktan Kontrol yönetimi (`/api/remote-control/`) için ana bilgisayarda loopback bağlantısı kullanın. Doğrudan LAN erişiminde de bu işlemler loopback gerektirir; bkz. [Yükseltme](remote-access.md#upgrading). Uzaktan Kontrol web önizlemeleri, adreslerindeki yetenek belirtecini kullanır ve ana bilgisayarda etkin oldukları sürece kullanılabilir kalır; bu adresi gizli tutun. ## Aktarma ve gizlilik {#relay-privacy} diff --git a/docs/help/tr/security.md b/docs/help/tr/security.md index f8f843f252..6d9b0de2ae 100644 --- a/docs/help/tr/security.md +++ b/docs/help/tr/security.md @@ -31,7 +31,11 @@ Komut bağımsız değişkenlerine veya kabuk geçmişine parola eklemek yerine Doğrudan giriş, arka uç belleğinde opak bir oturum ve `codenomad_session` çerezi (veya yapılandırılan adla çerez) oluşturur. Çerezler `HttpOnly`, `SameSite=Lax` özelliklerine sahiptir, `/` kapsamındadır ve HTTPS üzerinden verildiğinde `Secure` olarak işaretlenir. Bunlar tarayıcı oturumu çerezleridir; arka ucun yeniden başlatılması bellekteki oturumları geçersiz kılar. Başarısız değişiklik işlemlerini yeniden göndermek yerine istendiğinde aynı sayfada yeniden kimlik doğrulayın. -Parolayı değiştirmek sonraki kimlik bilgisi kontrollerini değiştirir; önceden verilmiş doğrudan oturumları veya Uzaktan Kontrol cihazlarını **geçersiz kılmaz**. Yetkisiz erişimden şüpheleniyorsanız: +Çıkış, mevcut doğrudan oturumu iptal eder. Ana bilgisayarda başarılı parola değişikliği, mevcut oturum dahil kullanıcının tüm doğrudan oturumlarını iptal eder; yeniden giriş yapın. Oturumlar, kimliği doğrulanmış istek olmadan 24 saat veya oluşturulduktan yedi gün sonra sona erer. Sınırları değiştirmek için arka uç başlamadan `CODENOMAD_SESSION_IDLE_SECONDS` ve `CODENOMAD_SESSION_ABSOLUTE_SECONDS` değişkenlerini pozitif tam sayı saniye olarak ayarlayın. Uzaktan Kontrol cihazları ayrı iptal edilir. + +LAN istemcileri giriş, çıkış ve proje işlemlerini kullanabilir. Parola değişikliği, Uzaktan Kontrol yönetimi, uzak sunucu/proxy araçları ve SideCars, ana bilgisayarın loopback bağlantısından yönetilir. Tarayıcı değişiklikleri izin verilen Origin veya yoksa Referer gerektirir. Sunucunun duyurulan kaynaklarına izin verilir; DNS takma adları, ters proxy veya ayrı barındırılan arayüz için `CODENOMAD_ALLOWED_ORIGINS` değişkenine `https://code.example.test,https://ui.example.test:8443` gibi tam HTTP(S) kaynaklarını virgülle ayırarak yazıp yeniden başlatın. Yol veya joker karakter kullanmayın. Bu, tarayıcı erişimi verir; ana bilgisayar yetkisi vermez. Yerel LAN istemcileri de değişikliklerde izin verilen Origin göndermelidir. Loopback üzerinden bağlanan güvenilir proxy, ana bilgisayar yetkisine sahiptir ve kendi erişim denetimlerine ihtiyaç duyar. + +Yetkisiz erişimden şüpheleniyorsanız: 1. Ağ erişimini kısıtlayın ve ana bilgisayarda yeni bir parola belirleyin veya çalışma zamanı sırrınızı değiştirin ve yeniden başlatın. 2. Mevcut doğrudan oturumlarını geçersiz kılmak için arka ucu yeniden başlatın. diff --git a/docs/help/zh-Hans/browser-sidecars.md b/docs/help/zh-Hans/browser-sidecars.md index 23c6ebc433..a485deb46b 100644 --- a/docs/help/zh-Hans/browser-sidecars.md +++ b/docs/help/zh-Hans/browser-sidecars.md @@ -69,7 +69,9 @@ 不可达时,检查后端进程和端口。HTML 可加载但资源/WebSocket 失败时,检查工具基础路径和前缀模式。以目标设置重新创建错误注册;不要仅为绕过代理错误而公开上游。SideCar 应用可能以服务器账户权限提供终端或文件;请信任其用户和配置。 -SideCar 是 CodeNomad 的可信扩展,而不是沙箱预览:其页面以 CodeNomad 自身的源运行,因此其脚本可以凭借你的登录访问权限使用 CodeNomad。只注册你信任其代码的服务。CodeNomad 不会将自身的登录 cookie 转发给该服务,但会保留该服务自己的 cookie。远程控制不暴露 SideCar 应用代理流量;请使用[远程访问](remote-access.md)和[远程控制](remote-control.md)中描述的适当直接访问路径。 +SideCar 是 CodeNomad 的可信扩展,而不是沙箱预览:其页面以 CodeNomad 自身的源运行,因此其脚本可以凭借你的登录访问权限使用 CodeNomad。只注册你信任其代码的服务。CodeNomad 不会将自身的登录 cookie 转发给该服务,但会保留该服务自己的 cookie。 + +SideCar 内容(`/sidecars/`)和 SideCar WebSocket 要求在主机上使用回环连接。通过 `/api/sidecars` 注册可供已配对设备和已认证的局域网直连客户端使用。持有有效能力令牌时,活动预览内容(`/previews/`)可通过局域网直接访问;[远程控制](remote-control.md#remote-surface)网页预览在启用时仍可通过其能力令牌访问。直接访问的要求请参阅[升级](remote-access.md#upgrading)。 ## OpenVSCode Server 示例 {#openvscode} diff --git a/docs/help/zh-Hans/remote-access.md b/docs/help/zh-Hans/remote-access.md index daccd613e6..7ff6075889 100644 --- a/docs/help/zh-Hans/remote-access.md +++ b/docs/help/zh-Hans/remote-access.md @@ -52,6 +52,14 @@ codenomad --host 127.0.0.1 --https=false --http=true 不要假定端口是 9898 或 9899:省略端口时,繁忙会回退。显式非零的 `--https-port`,或启用 HTTP 时的 `--http-port`,会固定端口,无法绑定则失败;`0` 请求操作系统分配。本次运行以打印的 **Local Connection URL**、**Remote Connection URL** 和刷新的**可访问地址**为准。参阅完整[服务器 CLI 参考](server.md)。 +## 升级 {#upgrading} + +升级后请重新登录。使用主机名别名、反向代理或单独托管的界面时,请在后端设置 `CODENOMAD_ALLOWED_ORIGINS`,列出可信的界面来源(协议、主机名和端口)。局域网原生客户端必须在修改请求中提供允许的 `Origin` 请求头。 + +仅限主机的操作需要回环连接:SideCar 内容(`/sidecars/`)及 WebSocket、远程控制管理(`/api/remote-control/`)、插件桥接(`/api/opencode-plugin/`)、远程代理(`/api/remote-proxy/`)、已保存服务器管理(`/api/remote-servers/`)、密码更改和令牌初始化。即使通过局域网直接访问并已登录,这些操作也要求回环连接。持有有效能力令牌时,活动预览内容(`/previews/`)可通过局域网直接访问。 + +`/api/sidecars` 注册 API 仍可供已认证的局域网直连客户端和已配对设备使用。远程控制网页预览在主机启用时,仍可通过其能力令牌访问。 + ## 配置可信 TLS {#tls} ### 提供自己的证书 {#custom-certificates} diff --git a/docs/help/zh-Hans/remote-control.md b/docs/help/zh-Hans/remote-control.md index a8af0180e7..04bf348603 100644 --- a/docs/help/zh-Hans/remote-control.md +++ b/docs/help/zh-Hans/remote-control.md @@ -56,12 +56,12 @@ | 密码登录/退出、密码更改、本地令牌初始化 | 不可用;即使禁用直接认证,仍需要设备凭据。 | | 远程控制状态 | 配对设备可读;管理不可用。启动/停止、创建配对、设备列表和撤销保留在主机访问路径上。 | | 桌面远程窗口/服务器管理及代理路由 | 不可用(`/api/remote-servers/`、`/api/remote-proxy/`)。 | -| SideCar **内容代理** | 不可用(`/sidecars/`)。这**不**表示所有 SideCar API 都被阻止:配对客户端仍可访问 `/api/sidecars` 注册 API。 | +| SideCar **内容代理** | 要求回环连接(`/sidecars/`),SideCar WebSocket 也一样。`/api/sidecars` 注册 API 可供已配对设备和已认证的局域网直连客户端使用。 | | 网页预览内容 | 主机上**设置 → 远程访问 → 远程控制**中的**网页预览**开启时(默认)可用。每个预览地址无需配对即可打开对应页面,请勿泄露。关闭该开关会停止已提供的预览及其 WebSocket;已配对设备重新加载后不再显示**打开网页预览**。 | | 自动化桥接 | 不可用(`/api/opencode-plugin/`)。原生桌面能力不会变成浏览器能力。 | | WebSocket 升级 | 仅适用于网页预览。 | -工作流程需要被阻止的代理/WebSocket 界面时,使用妥善保护的[直接连接](remote-access.md)。远程控制不会自动切换到该路径。 +仅限主机的代理/WebSocket 功能和远程控制管理(`/api/remote-control/`)需要在主机上使用回环连接。通过局域网直接访问时,这些操作也要求回环连接;请参阅[升级](remote-access.md#upgrading)。远程控制网页预览使用地址中的能力令牌,在主机启用时仍然可用;请勿泄露该地址。 ## 中继与隐私 {#relay-privacy} diff --git a/docs/help/zh-Hans/security.md b/docs/help/zh-Hans/security.md index ca7714e193..0cb6e92896 100644 --- a/docs/help/zh-Hans/security.md +++ b/docs/help/zh-Hans/security.md @@ -31,7 +31,11 @@ 直接登录会创建不透明的后端内存会话和 `codenomad_session` cookie(或配置的 cookie 名)。cookie 为 `HttpOnly`、`SameSite=Lax`,作用域为 `/`,通过 HTTPS 签发时标记为 `Secure`。它们是浏览器会话 cookie;后端重启使内存会话失效。收到提示时请就地重新认证,而非重发失败的修改操作。 -更改密码会改变后续凭据检查;**不会**使已签发的直接会话或远程控制设备失效。怀疑被入侵时: +退出登录会撤销当前直接会话。在主机成功更改密码会撤销该用户的所有直接会话,包括当前会话;请重新登录。会话在 24 小时没有经过认证的请求后,或创建七天后过期。要调整期限,请在启动后端前将 `CODENOMAD_SESSION_IDLE_SECONDS` 和 `CODENOMAD_SESSION_ABSOLUTE_SECONDS` 设为正整数秒。远程控制设备需单独撤销。 + +LAN 客户端可以登录、退出和使用项目。密码更改、远程控制管理、远程服务器/代理工具和 SideCars 由主机通过回环连接管理。浏览器修改请求须提供允许的 Origin,缺少 Origin 时则检查 Referer。服务器公布的来源默认允许;如使用 DNS 别名、反向代理或单独托管的界面,请将 `CODENOMAD_ALLOWED_ORIGINS` 设为逗号分隔的精确 HTTP(S) 来源,例如 `https://code.example.test,https://ui.example.test:8443`,然后重启。不要包含路径或通配符。这仅授予浏览器访问权,不授予主机权限。原生 LAN 客户端修改请求也须发送允许的 Origin。通过回环连接的可信代理具有主机权限,须自行实施访问控制。 + +怀疑被入侵时: 1. 限制网络暴露并在主机设置新密码,或更改运行时秘密并重启。 2. 重启后端,使现有直接会话失效。 diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 43341e3edc..a6a5f7edf5 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -943,6 +943,8 @@ export interface SupportMeta { } export interface ServerMeta { + /** Whether this request has host-local privileges (loopback, outside Remote Control). */ + localRequest?: boolean /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */ localUrl: string /** URL direct remote clients should use (prefers HTTPS when enabled). */ diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index 3892f3a6fe..b299f3f6cd 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -18,17 +18,20 @@ export interface AuthManagerInit { generateToken: boolean dangerouslySkipAuth?: boolean cookieName?: string + sessionIdleMs?: number + sessionAbsoluteMs?: number } export class AuthManager { private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null - private readonly sessionManager = new SessionManager() + private readonly sessionManager: SessionManager private readonly cookieName: string private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { this.cookieName = sanitizeCookieName(init.cookieName) + this.sessionManager = new SessionManager({ idleMs: init.sessionIdleMs, absoluteMs: init.sessionAbsoluteMs }) this.authEnabled = !Boolean(init.dangerouslySkipAuth) if (!this.authEnabled) { @@ -93,11 +96,18 @@ export class AuthManager { return this.requireAuthStore().getStatus() } - setPassword(password: string) { + async setPassword(password: string) { if (!this.authEnabled) { throw new Error("Internal authentication is disabled") } - return this.requireAuthStore().setPassword({ password, markUserProvided: true }) + const status = await this.requireAuthStore().setPassword({ password, markUserProvided: true }) + this.sessionManager.revokeUser(status.username) + return status + } + + revokeSession(request: FastifyRequest): void { + const session = this.getSessionFromRequest(request) + if (session) this.sessionManager.revoke(session.sessionId) } /** From this machine's own listeners; tunnelled Remote Control traffic never qualifies. */ diff --git a/packages/server/src/auth/session-manager.test.ts b/packages/server/src/auth/session-manager.test.ts new file mode 100644 index 0000000000..4212c2ed3c --- /dev/null +++ b/packages/server/src/auth/session-manager.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as sessionModule from "./session-manager" + +const { SessionManager, sessionTimeoutMs } = sessionModule + +test("sessions slide until idle or absolute expiry and revocation isolates principals", () => { + let now = 0 + const sessions = new SessionManager({ idleMs: 10, absoluteMs: 25, now: () => now }) + const first = sessions.createSession("alice") + const second = sessions.createSession("alice") + const other = sessions.createSession("bob") + sessions.revoke(first.id) + assert.equal(sessions.getSession(first.id), undefined) + assert.ok(sessions.getSession(second.id)) + sessions.revokeUser("alice") + assert.equal(sessions.getSession(second.id), undefined) + for (now of [9, 18, 24]) assert.ok(sessions.getSession(other.id)) + now = 25 + assert.equal(sessions.getSession(other.id), undefined, "activity cannot extend the absolute lifetime") + const idle = sessions.createSession("bob") + now = 35 + assert.equal(sessions.getSession(idle.id), undefined, "idle expiry includes its boundary") + now = 36 + assert.equal(sessions.getSession(idle.id), undefined, "an expired token stays revoked") +}) + +test("session defaults expire at 24 idle hours or seven absolute days; configuration is positive seconds", () => { + let now = 0 + const sessions = new SessionManager({ now: () => now }) + const idle = sessions.createSession("alice") + now = 24 * 60 * 60 * 1000 + assert.equal(sessions.getSession(idle.id), undefined) + const active = sessions.createSession("alice") + for (let hour = 1; hour < 7 * 24; hour++) { + now += 60 * 60 * 1000 + assert.ok(sessions.getSession(active.id)) + } + now += 60 * 60 * 1000 + assert.equal(sessions.getSession(active.id), undefined) + assert.equal(sessionTimeoutMs("60", "TIMEOUT"), 60_000) + assert.equal(sessionTimeoutMs(undefined, "TIMEOUT"), undefined) + for (const value of ["", "0", "-1", "NaN", "1.5", "Infinity", "99999999999999999"]) { + assert.throws(() => sessionTimeoutMs(value, "TIMEOUT")) + } +}) diff --git a/packages/server/src/auth/session-manager.ts b/packages/server/src/auth/session-manager.ts index e952ae1bfc..705de2c409 100644 --- a/packages/server/src/auth/session-manager.ts +++ b/packages/server/src/auth/session-manager.ts @@ -3,21 +3,56 @@ import crypto from "crypto" export interface SessionInfo { id: string createdAt: number + lastSeenAt: number username: string } export class SessionManager { private sessions = new Map() + constructor(private readonly options: { idleMs?: number; absoluteMs?: number; now?: () => number } = {}) {} + + private now() { return this.options.now?.() ?? Date.now() } + + private expired(session: SessionInfo, now: number): boolean { + return now - session.lastSeenAt >= (this.options.idleMs ?? 24 * 60 * 60 * 1000) + || now - session.createdAt >= (this.options.absoluteMs ?? 7 * 24 * 60 * 60 * 1000) + } + createSession(username: string): SessionInfo { const id = crypto.randomBytes(32).toString("base64url") - const info: SessionInfo = { id, createdAt: Date.now(), username } + const now = this.now() + for (const [id, session] of this.sessions) if (this.expired(session, now)) this.sessions.delete(id) + const info: SessionInfo = { id, createdAt: now, lastSeenAt: now, username } this.sessions.set(id, info) return info } getSession(id: string | undefined): SessionInfo | undefined { if (!id) return undefined - return this.sessions.get(id) + const session = this.sessions.get(id) + if (!session) return undefined + const now = this.now() + if (this.expired(session, now)) { + this.sessions.delete(id) + return undefined + } + session.lastSeenAt = now + return session + } + + revoke(id: string): void { this.sessions.delete(id) } + + revokeUser(username: string): void { + for (const [id, session] of this.sessions) if (session.username === username) this.sessions.delete(id) + } +} + +export function sessionTimeoutMs(value: string | undefined, name: string): number | undefined { + if (value === undefined) return undefined + const seconds = Number(value) + if (!/^\d+$/.test(value) || !Number.isSafeInteger(seconds * 1000) || seconds <= 0) { + throw new Error(`${name} must be a positive integer number of seconds`) } + return seconds * 1000 } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 15bea4975e..a2c56a7ec3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,8 @@ import path from "path" import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" +import { parseAllowedOrigins } from "./server/direct-origin" +import { sessionTimeoutMs } from "./auth/session-manager" import { PanelExtensionStore } from "./panel-extensions/store" import { WorkspaceManager } from "./workspaces/manager" import { resolveConfigLocation } from "./config/location" @@ -322,6 +324,8 @@ async function main() { username: options.authUsername, password: options.authPassword, cookieName: options.authCookieName, + sessionIdleMs: sessionTimeoutMs(process.env.CODENOMAD_SESSION_IDLE_SECONDS, "CODENOMAD_SESSION_IDLE_SECONDS"), + sessionAbsoluteMs: sessionTimeoutMs(process.env.CODENOMAD_SESSION_ABSOLUTE_SECONDS, "CODENOMAD_SESSION_ABSOLUTE_SECONDS"), generateToken: options.generateToken, dangerouslySkipAuth: options.dangerouslySkipAuth, }, @@ -527,6 +531,7 @@ async function main() { // The same application behind every listener of this backend. const listenerDeps = { + allowedOrigins: parseAllowedOrigins(process.env.CODENOMAD_ALLOWED_ORIGINS), memoryStore, workspaceManager, settings, diff --git a/packages/server/src/remote-control/gate.ts b/packages/server/src/remote-control/gate.ts index b7453f77d6..f040fd7574 100644 --- a/packages/server/src/remote-control/gate.ts +++ b/packages/server/src/remote-control/gate.ts @@ -59,11 +59,13 @@ export function gateRemoteRequest(request: FastifyRequest, reply: FastifyReply, if (previewToken && deps.servesPreview(previewToken)) return false const method = request.method.toUpperCase() - if (!SAFE_METHODS.has(method) && request.headers.origin !== `https://${host}`) { + const nativePairing = pathname === PAIR_EXCHANGE_PATH && request.headers.origin === undefined + && request.headers.referer === undefined && request.headers["sec-fetch-site"] === undefined + if (!SAFE_METHODS.has(method) && request.headers.origin !== `https://${host}` && !nativePairing) { reject(reply, 403, "Cross-origin request rejected") return true } - if (isLocalOnly(pathname) && !PUBLIC_PATHS.has(pathname) && !(method === "GET" && REMOTE_MANAGEMENT_READS.has(pathname))) { + if (isHostOnlyPath(pathname, method) && !PUBLIC_PATHS.has(pathname)) { reject(reply, 404, "Not found") return true } @@ -113,6 +115,9 @@ function isStaticBundlePath(pathname: string): boolean { && !pathname.startsWith("/api/") && !pathname.startsWith("/workspaces/") } -function isLocalOnly(pathname: string): boolean { +export function isHostOnlyPath(pathname: string, method: string, direct = false): boolean { + // Direct browsers sign in/out with the password; token bootstrap stays local. + if (direct && ["/login", "/api/auth/login", "/api/auth/logout"].includes(pathname)) return false + if (method === "GET" && REMOTE_MANAGEMENT_READS.has(pathname)) return false return LOCAL_ONLY_PATHS.has(pathname) || LOCAL_ONLY_PREFIXES.some((prefix) => pathname.startsWith(prefix) || `${pathname}/` === prefix) } diff --git a/packages/server/src/remote-control/remote-surface.test.ts b/packages/server/src/remote-control/remote-surface.test.ts index 578474e1b2..2540223b76 100644 --- a/packages/server/src/remote-control/remote-surface.test.ts +++ b/packages/server/src/remote-control/remote-surface.test.ts @@ -101,6 +101,26 @@ test("pairing requires the public origin and issues a host-only secure cookie on assert.equal(replay.status, 401) }) +test("server-to-server pairing uses its one-use code without Origin, while browser pairing requires its origin", async t => { + const { target, code } = await startRemoteSurface(t) + const browserHeaders: Record[] = [{ origin: "https://other.tunnel.test" }, { "sec-fetch-site": "cross-site" }, { referer: "https://other.tunnel.test/" }] + for (const headers of browserHeaders) { + const denied = await send(target(), "/api/remote-control/pair", { method: "POST", + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ code }) }) + assert.equal(denied.status, 403) + } + const native = await send(target(), "/api/remote-control/pair", { method: "POST", + headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) }) + assert.equal(native.status, 200) + assert.ok(native.headers["set-cookie"]) + const replay = await send(target(), "/api/remote-control/pair", { method: "POST", + headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) }) + assert.equal(replay.status, 401) + const cookie = String(native.headers["set-cookie"]).split(";")[0]! + const mutation = await send(target(), "/api/test/echo", { method: "POST", headers: { cookie } }) + assert.equal(mutation.status, 403) +}) + test("a paired device uses the app without loopback privileges or spoofed metadata", async (t) => { const { target, code } = await startRemoteSurface(t) const cookie = await pair(target(), code) @@ -149,6 +169,20 @@ test("host-only interfaces stay closed to paired devices, including encoded path } }) +test("paired devices retain SideCar registration API access without content or local privileges", async t => { + const { target, code } = await startRemoteSurface(t) + assert.equal((await send(target(), "/api/sidecars")).status, 401) + const cookie = await pair(target(), code) + const headers = { cookie, origin: ORIGIN, "content-type": "application/json" } + assert.equal((await send(target(), "/api/sidecars", { headers })).status, 200) + const created = await send(target(), "/api/sidecars", { method: "POST", headers, + body: JSON.stringify({ name: "Fixture", port: 3000 }) }) + assert.equal(created.status, 201, created.body) + assert.equal((await send(target(), "/sidecars/fixture/", { headers })).status, 404) + const meta = JSON.parse((await send(target(), "/api/meta", { headers })).body) + assert.equal(meta.localRequest, false) +}) + // Writes a raw request line, which the HTTP client would otherwise normalize. function sendRaw(target: string, requestTarget: string, method: string, cookie: string): Promise { const [hostname, port] = target.split(":") diff --git a/packages/server/src/remote-control/test-support/surface.ts b/packages/server/src/remote-control/test-support/surface.ts index fc2c15f7d1..0fb9a10ffa 100644 --- a/packages/server/src/remote-control/test-support/surface.ts +++ b/packages/server/src/remote-control/test-support/surface.ts @@ -22,6 +22,9 @@ const logger = createLogger({ level: "silent" } as never) */ export async function startRemoteSurface(t: TestContext, options: { skipAuth?: boolean + bindHost?: string + allowedOrigins?: string[] + configureApp?: (app: ReturnType["instance"]) => void openTunnel?: (options: OpenRemoteTunnelOptions) => Promise /** Real panel extension routes over this store, with `workspaceManager` in place of the stub. */ panelExtensions?: { store: PanelExtensionStore; workspaceManager: unknown } @@ -71,7 +74,8 @@ export async function startRemoteSurface(t: TestContext, options: { const stub = new Proxy({}, { get: () => () => undefined }) as never const server = createHttpServer({ - bindHost: "127.0.0.1", + bindHost: options.bindHost ?? "127.0.0.1", + allowedOrigins: options.allowedOrigins, bindPort: 0, defaultPort: 0, protocol: "http", @@ -108,6 +112,7 @@ export async function startRemoteSurface(t: TestContext, options: { const timer = setInterval(() => reply.raw.write("data: tick\n\n"), 20) reply.raw.on("close", () => clearInterval(timer)) }) + options.configureApp?.(app) options.routes?.(app) const started = await server.start() t.after(async () => { @@ -116,6 +121,8 @@ export async function startRemoteSurface(t: TestContext, options: { }) const { pairing } = await manager.start() return { + app, + authManager, manager, previewManager, pairing, diff --git a/packages/server/src/server/__tests__/cors-preflight.test.ts b/packages/server/src/server/__tests__/cors-preflight.test.ts index 5fed2d727e..00ab0effe7 100644 --- a/packages/server/src/server/__tests__/cors-preflight.test.ts +++ b/packages/server/src/server/__tests__/cors-preflight.test.ts @@ -19,7 +19,7 @@ function preflight(target: string, origin: string, method: string): Promise<{ st } test("allowed cross-origin clients may preflight every method the API accepts", async (t) => { - const { local } = await startRemoteSurface(t) + const { local } = await startRemoteSurface(t, { allowedOrigins: ["http://localhost:3000"] }) for (const method of ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]) { const response = await preflight(local, "http://localhost:3000", method) assert.equal(response.status, 204, method) diff --git a/packages/server/src/server/__tests__/sidecar-proxy.test.ts b/packages/server/src/server/__tests__/sidecar-proxy.test.ts index 881556e10f..7d51428589 100644 --- a/packages/server/src/server/__tests__/sidecar-proxy.test.ts +++ b/packages/server/src/server/__tests__/sidecar-proxy.test.ts @@ -43,6 +43,7 @@ async function startSideCarUpstream(options: UpstreamOptions = {}) { const requests: http.IncomingHttpHeaders[] = [] const received: Array<{ method: string; url: string; body: string }> = [] const connections = new Set() + let connectionCount = 0 const upstream = http.createServer((request, response) => { requests.push(request.headers) let body = "" @@ -61,6 +62,7 @@ async function startSideCarUpstream(options: UpstreamOptions = {}) { }) }) upstream.on("connection", (socket) => { + connectionCount++ connections.add(socket) socket.on("close", () => connections.delete(socket)) }) @@ -80,15 +82,27 @@ async function startSideCarUpstream(options: UpstreamOptions = {}) { socket.write(header.slice(0, split)) setImmediate(() => { if (!socket.destroyed) socket.write(header.slice(split)) }) } else socket.write(header) + // ponytail: fixture exchanges one short text frame; use a WS library for larger/fragmented messages. + let bytes = Buffer.alloc(0) + socket.on("data", chunk => { + bytes = Buffer.concat([bytes, chunk]) + if (bytes.length < 6) return + const length = bytes[1] & 127 + if (bytes.length < 6 + length) return + const mask = bytes.subarray(2, 6) + const payload = Buffer.from(bytes.subarray(6, 6 + length).map((value, i) => value ^ mask[i % 4])) + socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload])) + bytes = bytes.subarray(6 + length) + }) }) upstreams.push(upstream) upstreamSocketCleanups.push(() => { for (const socket of connections) socket.destroy() }) await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)) - return { port: (upstream.address() as net.AddressInfo).port, upgrades, requests, received } + return { port: (upstream.address() as net.AddressInfo).port, upgrades, requests, received, connectionCount: () => connectionCount } } /** Raw WebSocket handshake, like a browser's; resolves once the status line and headers arrived. */ -function webSocketUpgrade(port: number, pathname: string, headers: Record): Promise<{ status: number; head: string }> { +function webSocketUpgrade(port: number, pathname: string, headers: Record, exchange = false): Promise<{ status: number; head: string; echo?: string }> { return new Promise((resolve, reject) => { const socket = net.connect(port, "127.0.0.1", () => { const key = crypto.randomBytes(16).toString("base64") @@ -106,21 +120,34 @@ function webSocketUpgrade(port: number, pathname: string, headers: Record { reject(new Error(`WebSocket handshake timed out: ${pathname}`)) }) + let bytes = Buffer.alloc(0) let head = "" socket.on("data", (chunk) => { - head += chunk.toString("latin1") - const end = head.indexOf("\r\n\r\n") - if (end < 0) return + bytes = Buffer.concat([bytes, chunk]) + if (!head) { + const end = bytes.indexOf("\r\n\r\n") + if (end < 0) return + head = bytes.toString("latin1", 0, end) + bytes = bytes.subarray(end + 4) + if (exchange && Number(head.split(" ")[1]) === 101) { + const payload = Buffer.from("sidecar-origin-check") + const mask = crypto.randomBytes(4) + socket.write(Buffer.concat([Buffer.from([0x81, 0x80 | payload.length]), mask, + Buffer.from(payload.map((value, i) => value ^ mask[i % 4]))])) + } + } + const status = Number(head.split(" ")[1]) + if (exchange && status === 101 && (bytes.length < 2 || bytes.length < 2 + (bytes[1] & 127))) return // Abort like a browser tab close: a FIN would leave the proxied // connection half-open and the servers would never finish closing. socket.resetAndDestroy() - resolve({ status: Number(head.split(" ")[1]), head: head.slice(0, end) }) + resolve({ status, head, ...(exchange && status === 101 ? { echo: bytes.subarray(2, 2 + (bytes[1] & 127)).toString() } : {}) }) }) socket.on("error", reject) }) } -async function startServer(options: UpstreamOptions & { cookieName?: string; prefixMode?: SideCar["prefixMode"] } = {}) { +async function startServer(options: UpstreamOptions & { cookieName?: string; prefixMode?: SideCar["prefixMode"]; skipAuth?: boolean; allowedOrigins?: string[]; devServerUrl?: string } = {}) { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codenomad-sidecar-proxy-")) const logger = pino({ level: "silent" }) const authManager = new AuthManager({ @@ -129,8 +156,9 @@ async function startServer(options: UpstreamOptions & { cookieName?: string; pre password: "test-password", generateToken: false, cookieName: options.cookieName, + dangerouslySkipAuth: options.skipAuth, }, logger) - const { port, upgrades, requests, received } = await startSideCarUpstream(options) + const { port, upgrades, requests, received, connectionCount } = await startSideCarUpstream(options) const sidecar: SideCar = { id: "fixture", kind: "port", name: "ttyd", port, insecure: true, prefixMode: options.prefixMode ?? "strip", status: "running", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -160,6 +188,7 @@ async function startServer(options: UpstreamOptions & { cookieName?: string; pre clientConnectionManager: stub, remoteProxySessionManager: stub, yoloManager: stub, authManager, logger, nativeParent: stub, automationBridgeToken: "fixture-token", remoteControlManager: stub, uiStaticDir: directory, + allowedOrigins: options.allowedOrigins, uiDevServerUrl: options.devServerUrl, }) apps.push(app) await app.listen({ host: "127.0.0.1", port: 0 }) @@ -168,6 +197,7 @@ async function startServer(options: UpstreamOptions & { cookieName?: string; pre const session = authManager.createSession("codenomad") return { app, + connectionCount, port: address.port, origin: `http://127.0.0.1:${address.port}`, cookie: `${authManager.getCookieName()}=${session.id}`, @@ -200,6 +230,76 @@ it("SideCar WebSocket upgrades require the SameSite session cookie a same-origin } }) +it("non-local SideCar upgrades are refused even with a valid host session and spoofed forwarding headers", async () => { + const server = await startServer() + try { + // A deterministic transport peer, as app.inject's remoteAddress for HTTP. + server.app.server.prependListener("connection", socket => { + Object.defineProperty(socket, "remoteAddress", { value: "192.0.2.20" }) + }) + for (const origin of [server.origin, undefined]) { + const response = await webSocketUpgrade(server.port, "/sidecars/fixture/socket", { + Cookie: server.cookie, ...(origin ? { Origin: origin } : {}), "X-Forwarded-For": "127.0.0.1", + }) + assert.equal(response.status, 404) + assert.equal(server.connectionCount(), 0) + } + assert.deepEqual(server.upgrades, []) + } finally { await fs.rm(server.cleanupDirectory, { recursive: true, force: true }) } +}) + +for (const skipAuth of [false, true]) { + it(`SideCar WS enforces the shared source policy before any upstream connection (skip auth: ${skipAuth})`, async () => { + const allowed = "https://ui.example.test:8443" + const development = "http://localhost:3000" + const server = await startServer({ skipAuth, allowedOrigins: [allowed], devServerUrl: development }) + try { + const credentials: Record = skipAuth ? {} : { cookie: server.cookie } + const foreign = "http://localhost:1" + const denied: Record[] = [ + { origin: foreign }, { origin: "null" }, { origin: "not-an-origin" }, + { origin: `${server.origin}/path` }, { origin: `${allowed}, ${server.origin}` }, + { origin: "" }, { referer: `${foreign}/page` }, { referer: "malformed" }, + { origin: foreign, referer: `${server.origin}/page` }, + { origin: foreign, "x-forwarded-host": `localhost:${server.port}`, "x-forwarded-proto": "http" }, + ] + for (const headers of denied) { + for (const auth of skipAuth ? [{}] : [credentials, {}]) { + const before = server.connectionCount() + const response = await webSocketUpgrade(server.port, "/sidecars/fixture/ws", { ...auth, ...headers }, true) + assert.equal(response.status, 403, JSON.stringify(headers)) + assert.equal(server.connectionCount(), before, "refuse before even opening an upstream TCP connection") + } + } + const accepted: Record[] = [ + { origin: server.origin }, { origin: `http://localhost:${server.port}` }, { origin: allowed }, { origin: development }, + {}, { referer: `${server.origin}/page` }, { referer: `${allowed}/page` }, + { origin: server.origin, referer: `${foreign}/page` }, + ] + for (const headers of accepted) { + const before = server.connectionCount() + const response = await webSocketUpgrade(server.port, "/sidecars/fixture/ws", { ...credentials, ...headers }, true) + assert.equal(response.status, 101, JSON.stringify(headers)) + assert.equal(response.echo, "sidecar-origin-check") + assert.equal(server.connectionCount(), before + 1) + } + if (!skipAuth) { + const anonymous: Record[] = [{}, { origin: server.origin }, { origin: allowed }] + for (const headers of anonymous) { + const before = server.connectionCount() + assert.equal((await webSocketUpgrade(server.port, "/sidecars/fixture/ws", headers)).status, 401) + assert.equal(server.connectionCount(), before) + } + const logout = await server.app.inject({ method: "POST", url: "/api/auth/logout", headers: credentials }) + assert.equal(logout.statusCode, 200) + const before = server.connectionCount() + assert.equal((await webSocketUpgrade(server.port, "/sidecars/fixture/ws", { ...credentials, origin: allowed })).status, 401) + assert.equal(server.connectionCount(), before) + } + } finally { await fs.rm(server.cleanupDirectory, { recursive: true, force: true }) } + }) +} + for (const prefixMode of ["strip", "preserve"] as const) { for (const cookieName of ["codenomad_session", "custom_session"]) { it(`filters application cookies in HTTP and WS both ways, retaining service cookies (${prefixMode}, ${cookieName})`, async () => { diff --git a/packages/server/src/server/direct-listener.test.ts b/packages/server/src/server/direct-listener.test.ts new file mode 100644 index 0000000000..00b713e142 --- /dev/null +++ b/packages/server/src/server/direct-listener.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { HTTPMethods } from "fastify" +import { startRemoteSurface } from "../remote-control/test-support/surface" +import { parseAllowedOrigins } from "./direct-origin" +import { PROXIED_HTTP_METHODS } from "./proxied-http-methods" + +const own = "http://localhost:0" // advertised origin in the real application fixture +const trusted = "https://ui.example.test" +const foreign = "https://other.example.test" +const lan = "192.0.2.20" + +test("the wildcard direct listener grants credentialed CORS only to self and configured origins", async t => { + const { app, authManager } = await startRemoteSurface(t, { bindHost: "0.0.0.0", allowedOrigins: [trusted] }) + const session = authManager.createSession("codenomad") + const cookie = `${authManager.getCookieName()}=${session.id}` + for (const origin of [own, trusted, foreign, "null", "http://localhost:3000", "https://ui.example.test.evil.test"]) { + const response = await app.inject({ url: "/api/auth/status", remoteAddress: lan, headers: { origin, cookie } }) + assert.equal(response.statusCode, origin === "null" ? 403 : 200) + assert.equal(response.headers["access-control-allow-origin"], [own, trusted].includes(origin) ? origin : undefined) + assert.notEqual(response.headers["access-control-allow-origin"], "*") + const preflight = await app.inject({ method: "OPTIONS", url: "/api/test/mutate", remoteAddress: lan, + headers: { origin, "access-control-request-method": "POST" } }) + assert.equal(preflight.headers["access-control-allow-origin"], [own, trusted].includes(origin) ? origin : undefined) + } + for (const value of ["*", "null", "https://ui.test/", "https://user:pass@ui.test", "https://*.ui.test", "file:///test"]) { + assert.throws(() => parseAllowedOrigins(value), value) + } + assert.deepEqual(parseAllowedOrigins(` ${trusted},${own} `), [trusted, own]) +}) + +test("direct mutations validate Origin then Referer, including login/logout and credential-free requests", async t => { + let writes = 0 + const { app, authManager } = await startRemoteSurface(t, { allowedOrigins: [trusted], + configureApp: app => { app.post("/api/test/mutate", async () => ({ writes: ++writes })) } }) + const cookie = `${authManager.getCookieName()}=${authManager.createSession("codenomad").id}` + for (const headers of [{ origin: own }, { origin: trusted }, { referer: `${trusted}/page` }]) { + const response = await app.inject({ method: "POST", url: "/api/test/mutate", remoteAddress: lan, headers: { cookie, ...headers } }) + assert.equal(response.statusCode, 200, response.body) + } + assert.equal(writes, 3) + for (const headers of [{}, { origin: foreign }, { origin: "null" }, { referer: "invalid" }, + { referer: `${foreign}/page` }, { origin: foreign, referer: `${trusted}/page` }]) { + for (const url of ["/api/test/mutate", "/api/auth/login", "/api/auth/logout"]) { + const response = await app.inject({ method: "POST", url, remoteAddress: lan, headers: { cookie, ...headers } }) + assert.equal(response.statusCode, 403, `${url}: ${JSON.stringify(headers)}`) + } + } + assert.equal(writes, 3) + const unauthenticated = await app.inject({ method: "POST", url: "/api/test/mutate", remoteAddress: lan, headers: { origin: own } }) + assert.equal(unauthenticated.statusCode, 401) + const login = await app.inject({ method: "POST", url: "/api/auth/login", remoteAddress: lan, headers: { origin: own }, + payload: { username: "codenomad", password: "local-password" } }) + assert.equal(login.statusCode, 200) + assert.ok(login.headers["set-cookie"]) + const localNative = await app.inject({ method: "POST", url: "/api/test/mutate", headers: { cookie } }) + assert.equal(localNative.statusCode, 200) +}) + +test("LAN sessions use the bundled UI but cannot administer host-only routes, including encoded paths", async t => { + const { app, authManager } = await startRemoteSurface(t) + const cookie = `${authManager.getCookieName()}=${authManager.createSession("codenomad").id}` + const headers = { cookie, origin: own, "x-forwarded-for": "127.0.0.1", "x-codenomad-remote-control": "0" } + for (const url of ["/", "/api/meta", "/api/auth/status", "/api/remote-control/status", "/api/test/echo"]) { + const response = await app.inject({ url, remoteAddress: lan, headers }) + assert.equal(response.statusCode, 200, `${url}: ${response.body}`) + if (url.endsWith("echo")) assert.equal(response.json().loopbackPrivileges, false) + } + for (const [method, url] of [ + ["POST", "/api/auth/password"], ["POST", "/api/auth/password;x"], ["POST", "/api/auth/token"], + ["GET", "/auth/token"], ["POST", "/api/remote-control/start"], ["POST", "/api/remote-%63ontrol/pairings"], + ["DELETE", "/api/remote-control"], ["GET", "/api/remote-control/devices"], + ["POST", "/api/remote-proxy/sessions"], ["POST", "/api/remote-servers/probe;x"], + ["POST", "/api/opencode-plugin/automation"], ["GET", "/sidecars/example/"], + ] as const) { + const response = await app.inject({ method, url, remoteAddress: lan, headers }) + assert.equal(response.statusCode, 404, `${method} ${url}: ${response.body}`) + } + const lanStatus = await app.inject({ url: "/api/remote-control/status", remoteAddress: lan, headers }) + const registration = await app.inject({ method: "POST", url: "/api/sidecars", remoteAddress: lan, headers, + payload: { name: "Fixture", port: 3000 } }) + assert.equal(registration.statusCode, 201, registration.body) + for (const remoteAddress of [lan, "127.0.0.1"]) { + const meta = await app.inject({ url: "/api/meta", remoteAddress, headers }) + assert.equal(meta.json().localRequest, remoteAddress === "127.0.0.1") + } + assert.equal(lanStatus.json().manageable, false) + const localDevices = await app.inject({ url: "/api/remote-control/devices", headers: { cookie } }) + assert.equal(localDevices.statusCode, 200) + const localStatus = await app.inject({ url: "/api/remote-control/status", headers: { cookie } }) + assert.notEqual(localStatus.json().manageable, false) + const logout = await app.inject({ method: "POST", url: "/api/auth/logout", remoteAddress: lan, headers }) + assert.equal(logout.statusCode, 200) + const status = await app.inject({ url: "/api/auth/status", remoteAddress: lan, headers: { cookie } }) + assert.equal(status.json().authenticated, false) +}) + +test("skipping auth does not bypass direct listener origin or host restrictions", async t => { + const { app } = await startRemoteSurface(t, { skipAuth: true }) + assert.equal((await app.inject({ method: "POST", url: "/api/auth/logout", remoteAddress: lan, headers: { origin: foreign } })).statusCode, 403) + assert.equal((await app.inject({ method: "POST", url: "/api/auth/password", remoteAddress: lan, headers: { origin: own } })).statusCode, 404) + assert.equal((await app.inject({ url: "/api/meta", remoteAddress: lan })).statusCode, 200) +}) + +test("extended proxy methods and over-long parameters retain direct admission checks", async t => { + const { app, authManager } = await startRemoteSurface(t, { + routes: app => { app.all("/api/test/proxy", async () => ({ ok: true })) }, + }) + const cookie = `${authManager.getCookieName()}=${authManager.createSession("codenomad").id}` + for (const [method] of PROXIED_HTTP_METHODS) { + for (const origin of [undefined, foreign, own]) { + const response = await app.inject({ method: method as HTTPMethods, url: "/api/test/proxy", remoteAddress: lan, + headers: { cookie, ...(origin ? { origin } : {}) } }) + assert.equal(response.statusCode, origin === own ? 200 : 403, `${method}: ${origin}`) + } + const sidecar = await app.inject({ method: method as HTTPMethods, url: "/sidecars/fixture/x", remoteAddress: lan, + headers: { cookie, origin: own } }) + assert.equal(sidecar.statusCode, 404, method) + } + const long = "x".repeat(101) + assert.equal((await app.inject({ method: "POST", url: `/sidecars/${long}/x`, remoteAddress: lan, + headers: { cookie, origin: own } })).statusCode, 404) + assert.equal((await app.inject({ method: "POST", url: `/api/workspaces/${long}`, remoteAddress: lan, + headers: { cookie, origin: foreign } })).statusCode, 403) +}) + +test("a preview capability Host cannot authorize application routes that take precedence over its wildcard", async t => { + const { app, previewManager } = await startRemoteSurface(t) + const preview = previewManager.create("fixture", "http://127.0.0.1:1") + const host = `${preview.token}.preview.localhost` + for (const remoteAddress of [lan, "127.0.0.1"]) { + for (const method of ["GET", "POST"] as const) { + const response = await app.inject({ method, url: "/api/sidecars", remoteAddress, headers: { host } }) + assert.equal(response.statusCode, method === "POST" && remoteAddress === lan ? 403 : 401, response.body) + } + const meta = await app.inject({ url: "/api/meta", remoteAddress, headers: { host } }) + assert.equal(meta.statusCode, 401) + const login = await app.inject({ method: "POST", url: "/api/auth/login", remoteAddress, + headers: { host, origin: "null" }, payload: { username: "codenomad", password: "local-password" } }) + assert.equal(login.statusCode, 403) + assert.equal(login.headers["access-control-allow-origin"], undefined) + } +}) diff --git a/packages/server/src/server/direct-origin.ts b/packages/server/src/server/direct-origin.ts new file mode 100644 index 0000000000..cb6d4b47b1 --- /dev/null +++ b/packages/server/src/server/direct-origin.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance, FastifyRequest } from "fastify" +import cors from "@fastify/cors" +import type { ServerMeta } from "../api-types" +import { isLocalRequest, isRemoteRequest } from "../remote-control/request-origin" + +type OriginRequest = Pick + +/** Explicit origins only: credentials, paths, wildcards and opaque origins fail startup. */ +export function parseAllowedOrigins(value = ""): string[] { + return value.split(",").map(value => value.trim()).filter(Boolean).map(value => { + const url = new URL(value) + if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.origin !== value || value.includes("*")) { + throw new Error("CODENOMAD_ALLOWED_ORIGINS must contain comma-separated HTTP(S) origins") + } + return url.origin + }) +} + +export function registerDirectOriginPolicy(app: FastifyInstance, options: { + meta: ServerMeta + allowedOrigins?: string[] + devServerUrl?: string + publicHost(): string | null + isPreview(request: FastifyRequest): boolean +}) { + const allows = (request: OriginRequest, origin: string): boolean => { + if (isRemoteRequest(request)) return origin === `https://${options.publicHost()}` + const candidates = [options.meta.localUrl, options.meta.remoteUrl, + ...(options.meta.addresses ?? []).map(address => address.remoteUrl), ...(options.allowedOrigins ?? [])] + // The listening socket supplies its own port even before startup metadata is published. + const address = app.server.address() + if (address && typeof address === "object") { + const host = address.address.includes(":") ? `[${address.address}]` : address.address + const protocol = request.raw.socket && "encrypted" in request.raw.socket ? "https" : "http" + candidates.push(`${protocol}://${host}:${address.port}`) + if (isLocalRequest(request) && (address.address === "127.0.0.1" || address.address === "::1")) { + candidates.push(`${protocol}://localhost:${address.port}`) + } + } + if (isLocalRequest(request) && options.devServerUrl) candidates.push(options.devServerUrl) + return candidates.some(candidate => { + try { return candidate !== undefined && new URL(candidate).origin === origin } catch { return false } + }) + } + + const allowsSource = (request: OriginRequest): boolean => { + const origin = request.headers.origin + let source = origin + if (source === undefined && request.headers.referer !== undefined) { + try { source = new URL(request.headers.referer).origin } catch { source = "null" } + } + // Desktop hosts and bundled native plugins make origin-less loopback calls. + return source === undefined ? isLocalRequest(request) : allows(request, source) + } + + app.addHook("onRequest", (request, reply, done) => { + if (isRemoteRequest(request) || ["GET", "HEAD", "OPTIONS"].includes(request.method) || options.isPreview(request)) return done() + if (allowsSource(request)) return done() + reply.header("connection", "close").code(403).send({ error: "Cross-origin request rejected" }) + }) + + app.register(cors, () => (request: FastifyRequest, cb: (error: Error | null, options: object) => void) => { + const origin = request.headers.origin + cb(null, { credentials: true, methods: "GET,HEAD,PUT,PATCH,POST,DELETE", origin: origin !== undefined && (allows(request, origin) + || (origin === "null" && options.isPreview(request))) }) + }) + return allowsSource +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index a4450736cb..8af5e8f274 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -1,5 +1,4 @@ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify" -import cors from "@fastify/cors" import fastifyStatic from "@fastify/static" import replyFrom from "@fastify/reply-from" import fs from "fs" @@ -90,8 +89,9 @@ import { sidecarRequestCookies, sidecarResponseCookies } from "../sidecars/proxy import { proxyWebSocket } from "./proxy-websocket" import type { PreviewManager } from "../previews/manager" import type { RemoteControlManager } from "../remote-control/manager" -import { gateRemoteRequest, PAIR_EXCHANGE_PATH, PAIR_PAGE_PATH } from "../remote-control/gate" -import { isRemoteRequest } from "../remote-control/request-origin" +import { gateRemoteRequest, isHostOnlyPath, PAIR_EXCHANGE_PATH, PAIR_PAGE_PATH } from "../remote-control/gate" +import { isLocalRequest, isRemoteRequest } from "../remote-control/request-origin" +import { registerDirectOriginPolicy } from "./direct-origin" import { framesNoBody } from "./bodiless-request" import { addProxiedHttpMethods } from "./proxied-http-methods" import { ROUTER_OPTIONS, routedPath } from "./routed-path" @@ -147,6 +147,7 @@ interface HttpServerDeps { permissionReceipts?: PermissionReceipts uiStaticDir: string uiDevServerUrl?: string + allowedOrigins?: string[] logger: Logger nativeParent: NativeParent automationBridgeToken: string @@ -166,8 +167,6 @@ interface HttpServerStartResult { displayHost: string } -const CORS_METHODS = "GET,HEAD,PUT,PATCH,POST,DELETE" - export function shouldRetryPreferredPort(error: unknown, autoPortRequested: boolean, platform = process.platform): boolean { if (!autoPortRequested) return false const code = (error as NodeJS.ErrnoException | undefined)?.code @@ -232,63 +231,28 @@ export function createHttpServer(deps: HttpServerDeps) { if (!gateRemoteRequest(request, reply, remoteGate)) done() }) - const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]) - const getSelfOrigins = (): Set => { - const origins = new Set() - const candidates: Array = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl] - for (const candidate of candidates) { - if (!candidate) continue - try { - origins.add(new URL(candidate).origin) - } catch { - // ignore - } - } - for (const addr of deps.serverMeta.addresses ?? []) { - try { - origins.add(new URL(addr.remoteUrl).origin) - } catch { - // ignore - } - } - return origins + const isLivePreview = (request: FastifyRequest) => { + // Capability admission belongs to the matched proxy route, never Host alone: + // explicit application routes take precedence over the host's wildcard route. + const config = request.routeOptions.config + if (!("previewCapability" in config) || config.previewCapability !== true) return false + const pathname = routedPath(request.raw.url ?? "/") + const token = parsePreviewCapabilityHost(request.headers.host) ?? pathname?.match(/^\/previews\/([^/]+)(?:\/|$)/)?.[1] + if (!token) return false + try { return Boolean(deps.previewManager.get(decodeURIComponent(token))) } catch { return false } } - - app.register(cors, { - origin: (origin, cb) => { - if (!origin) { - cb(null, true) - return - } - - const selfOrigins = getSelfOrigins() - if (selfOrigins.has(origin)) { - cb(null, true) - return - } - - if (origin === "null") { - cb(null, true) - return - } - - if (allowedDevOrigins.has(origin)) { - cb(null, true) - return - } - - // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access. - if (isWildcardHost(deps.bindHost) || !isLoopbackHost(deps.bindHost)) { - cb(null, true) - return - } - - - cb(null, false) - }, - // Every method the UI sends cross-origin; @fastify/cors defaults to the CORS-safelisted ones. - methods: CORS_METHODS, - credentials: true, + app.addHook("onRequest", (request, reply, done) => { + if (isLocalRequest(request) || isRemoteRequest(request)) return done() + const pathname = routedPath(request.raw.url ?? "/") + if (pathname === null) return reply.header("connection", "close").code(400).send({ error: "Invalid request path" }) + if (isHostOnlyPath(pathname, request.method, true) && !isLivePreview(request)) { + return reply.header("connection", "close").code(404).send({ error: "Not found" }) + } + done() + }) + const allowsDirectSource = registerDirectOriginPolicy(app, { + meta: deps.serverMeta, allowedOrigins: deps.allowedOrigins, devServerUrl: deps.uiDevServerUrl, + publicHost: () => remoteGate.publicHost(), isPreview: isLivePreview, }) app.register(replyFrom, { @@ -323,18 +287,7 @@ export function createHttpServer(deps: HttpServerDeps) { pathname.startsWith("/api/remote-proxy/sessions/") && deps.authManager.isLoopbackRequest(request) - const encodedPreviewToken = pathname.match(/^\/previews\/([^/]+)(?:\/|$)/)?.[1] - const hostPreviewToken = parsePreviewCapabilityHost(request.headers.host) - let isPreviewCapability = false - if (hostPreviewToken) { - isPreviewCapability = Boolean(deps.previewManager.get(hostPreviewToken)) - } else if (encodedPreviewToken) { - try { - isPreviewCapability = Boolean(deps.previewManager.get(decodeURIComponent(encodedPreviewToken))) - } catch { - // Malformed capability paths remain subject to normal authentication. - } - } + const isPreviewCapability = isLivePreview(request) if (request.headers.origin === "null" && !isPreviewCapability) { reply.code(403).send({ error: "Opaque origins may access preview capabilities only" }) @@ -479,6 +432,7 @@ export function createHttpServer(deps: HttpServerDeps) { sidecarManager: deps.sidecarManager, authManager: deps.authManager, logger: proxyLogger, + allowsSource: request => allowsDirectSource({ raw: request, headers: request.headers }), }) const previewUpgrade = setupPreviewWebSocketProxy(app, { previewManager: deps.previewManager, @@ -590,7 +544,7 @@ interface SideCarProxyDeps { } interface SideCarWebSocketProxyDeps extends SideCarProxyDeps { - authManager: AuthManager + allowsSource(request: import("http").IncomingMessage): boolean } interface PreviewProxyDeps { @@ -664,12 +618,13 @@ function registerPreviewProxyRoutes(app: FastifyInstance, deps: PreviewProxyDeps }) } - previewApp.all("/", { constraints: { host: capabilityHost } }, hostHandler) - previewApp.all("/*", { constraints: { host: capabilityHost } }, hostHandler) - previewApp.all("/previews/:token", async (request: FastifyRequest<{ Params: { token: string } }>, reply) => { + const config = { previewCapability: true } + previewApp.all("/", { config, constraints: { host: capabilityHost } }, hostHandler) + previewApp.all("/*", { config, constraints: { host: capabilityHost } }, hostHandler) + previewApp.all("/previews/:token", { config }, async (request: FastifyRequest<{ Params: { token: string } }>, reply) => { await proxyPreviewRequest({ request, reply, previewManager: deps.previewManager, logger: deps.logger, pathSuffix: "" }) }) - previewApp.all("/previews/:token/*", async (request: FastifyRequest<{ Params: { token: string; "*": string } }>, reply) => { + previewApp.all("/previews/:token/*", { config }, async (request: FastifyRequest<{ Params: { token: string; "*": string } }>, reply) => { await proxyPreviewRequest({ request, reply, @@ -710,6 +665,15 @@ function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocket if (!parsed) { return } + if (!isLocalRequest({ raw: request })) { + rejectUpgrade(socket as Socket, 404, "Not Found") + return + } + // Upgrades bypass Fastify hooks; use the HTTP mutation source policy before proxying. + if (!deps.allowsSource(request)) { + rejectUpgrade(socket as Socket, 403, "Forbidden") + return + } void proxySideCarWebSocketUpgrade({ request, @@ -2373,12 +2337,6 @@ async function proxySideCarWebSocketUpgrade(args: { return } - // Upgrades bypass the HTTP preHandler: refuse opaque origins here as it does. - if (request.headers.origin === "null") { - rejectUpgrade(socket, 403, "Forbidden") - return - } - const session = authManager.getSessionFromHeaders(request.headers) if (!session) { rejectUpgrade(socket, 401, "Unauthorized") diff --git a/packages/server/src/server/routes/auth-responsiveness.test.ts b/packages/server/src/server/routes/auth-responsiveness.test.ts index 44768b29bf..80c3a5490f 100644 --- a/packages/server/src/server/routes/auth-responsiveness.test.ts +++ b/packages/server/src/server/routes/auth-responsiveness.test.ts @@ -120,6 +120,27 @@ it("awaits login results for valid credentials, rejects wrong usernames and auth } }) +it("logout revokes only that token; a committed password change revokes every user session", async () => { + const f = await fixture() + try { + const cookie = () => `${f.manager.getCookieName()}=${f.manager.createSession(username).id}` + const first = cookie(), second = cookie() + const status = (cookie: string) => f.app.inject({ url: "/api/auth/status", headers: { cookie } }) + assert.equal((await status(first)).json().authenticated, true) + assert.equal((await f.app.inject({ method: "POST", url: "/api/auth/logout", headers: { cookie: first } })).statusCode, 200) + assert.equal((await status(first)).json().authenticated, false) + assert.equal((await status(second)).json().authenticated, true) + const third = cookie() + const changed = await f.app.inject({ method: "POST", url: "/api/auth/password", headers: { cookie: second }, payload: { password: "replacement-password" } }) + assert.equal(changed.statusCode, 200) + assert.match(String(changed.headers["set-cookie"]), /Max-Age=0/) + for (const value of [first, second, third]) assert.equal((await status(value)).json().authenticated, false) + const login = await f.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "replacement-password" } }) + assert.equal(login.statusCode, 200) + assert.equal((await status(String(login.headers["set-cookie"]))).json().authenticated, true) + } finally { await f.close() } +}) + it("rejects a pending login verified against a password replaced before verification returns", async (t) => { const f = await fixture() let release: (() => void) | undefined @@ -187,6 +208,8 @@ it("preserves unauthenticated, runtime-override and disabled-auth password error assert.equal(response.statusCode, 409) assert.match(response.body, options.password ? /provided via CLI\/env/ : /authentication is disabled/) assert.match(String(response.headers["content-type"]), /^text\/plain/) + const status = await f.app.inject({ url: "/api/auth/status", headers: { cookie: `${f.manager.getCookieName()}=${session.id}` } }) + assert.equal(status.json().authenticated, true, "a failed password change must preserve the current session") } if (options.password) assert.equal(await f.manager.validateLogin(username, password), true) if (options.dangerouslySkipAuth) assert.equal(await f.manager.validateLogin("any", "anything"), true) diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts index 2c77b74383..4c58bcc408 100644 --- a/packages/server/src/server/routes/auth.ts +++ b/packages/server/src/server/routes/auth.ts @@ -135,6 +135,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.post("/api/auth/logout", async (request, reply) => { + deps.authManager.revokeSession(request) deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) }) reply.send({ ok: true }) }) @@ -149,6 +150,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { const body = PasswordSchema.parse(request.body ?? {}) try { const status = await deps.authManager.setPassword(body.password) + deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) }) reply.send({ ok: true, ...status }) } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index 771be9b83e..3ab9e6827a 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from "fastify" import { ServerMeta } from "../../api-types" -import { isRemoteRequest } from "../../remote-control/request-origin" +import { isLocalRequest, isRemoteRequest } from "../../remote-control/request-origin" import { isLoopbackHost, isWildcardHost } from "../network-host" import { serverOperatingSystemName } from "../os-name" @@ -13,6 +13,7 @@ interface RouteDeps { export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/meta", async (request) => ({ ...buildMetaResponse(deps.serverMeta), + localRequest: isLocalRequest(request), ...(isRemoteRequest(request) && !deps.remotePreviewsAllowed() ? { previewsUnavailable: true as const } : {}), })) } diff --git a/packages/server/src/server/routes/remote-control.ts b/packages/server/src/server/routes/remote-control.ts index 922d87ec90..faf26fc331 100644 --- a/packages/server/src/server/routes/remote-control.ts +++ b/packages/server/src/server/routes/remote-control.ts @@ -3,7 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import { z } from "zod" import type { RemoteControlManager } from "../../remote-control/manager" import { deviceCookie, PAIR_EXCHANGE_PATH, PAIR_PAGE_PATH } from "../../remote-control/gate" -import { isRemoteRequest } from "../../remote-control/request-origin" +import { isLocalRequest, isRemoteRequest } from "../../remote-control/request-origin" interface RouteDeps { manager: RemoteControlManager @@ -18,7 +18,7 @@ let pairTemplate: string | null = null export function registerRemoteControlRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/remote-control/status", async (request) => { const status = deps.manager.status() - return isRemoteRequest(request) ? { ...status, manageable: false } : status + return !isLocalRequest(request) ? { ...status, manageable: false } : status }) app.post("/api/remote-control/start", async (request, reply) => { @@ -100,7 +100,7 @@ export function registerRemoteControlRoutes(app: FastifyInstance, deps: RouteDep } function requireLocalControl(request: FastifyRequest, reply: FastifyReply): boolean { - if (isRemoteRequest(request)) { + if (!isLocalRequest(request)) { reply.code(403).send({ error: "Remote Control settings are available on the host only" }) return false } diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 6bbcd75a74..a6e5f666db 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -19,6 +19,7 @@ import { renameProject } from "../stores/project-names" import { LocaleSelector } from "./locale-selector" import { createNewInstanceDialogs } from "./new-instance-dialogs" import { useRemoteServerProfiles } from "../lib/hooks/use-remote-server-profiles" +import { getServerMeta, localRequest } from "../lib/server-meta" import { formatRelativeTime, getDisplayPath, getProjectDisplayName } from "../lib/recent-folder-display" const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" @@ -58,7 +59,8 @@ const FolderSelectionView: Component = (props) => { const folders = () => recentFolders() const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) - const canUseRemoteServerWindows = () => canOpenRemoteWindows() + const canUseRemoteServerWindows = () => localRequest() && canOpenRemoteWindows() + onMount(() => { void getServerMeta().catch(() => {}) }) function getActiveListLength() { return activeTab() === "local" ? folders().length : serverList().length @@ -784,7 +786,7 @@ const FolderSelectionView: Component = (props) => { - + - + - +
{ const { t } = useI18n() const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() const [dialogOpen, setDialogOpen] = createSignal(false) + onMount(() => { void getServerMeta().catch(() => {}) }) return ( - <> +
@@ -62,6 +64,6 @@ export const SavedRemoteServersCard: Component = () => {
- + ) } diff --git a/packages/ui/src/components/sidecar-picker-dialog.tsx b/packages/ui/src/components/sidecar-picker-dialog.tsx index be2ce57fc2..416bb08cae 100644 --- a/packages/ui/src/components/sidecar-picker-dialog.tsx +++ b/packages/ui/src/components/sidecar-picker-dialog.tsx @@ -1,7 +1,8 @@ import { Dialog } from "@kobalte/core/dialog" -import { For, Show, createEffect, createMemo, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onMount, type Component } from "solid-js" import { Globe, Square } from "lucide-solid" import { useI18n } from "../lib/i18n" +import { getServerMeta, localRequest } from "../lib/server-meta" import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars" interface SideCarPickerDialogProps { @@ -12,16 +13,17 @@ interface SideCarPickerDialogProps { export const SideCarPickerDialog: Component = (props) => { const { t } = useI18n() + onMount(() => { void getServerMeta().catch(() => {}) }) const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name))) createEffect(() => { - if (props.open) { + if (props.open && localRequest()) { void ensureSidecarsLoaded() } }) return ( - !open && props.onClose()}> + !open && props.onClose()}>
diff --git a/packages/ui/src/lib/server-meta.ts b/packages/ui/src/lib/server-meta.ts index 972f58e89d..cc37330f4e 100644 --- a/packages/ui/src/lib/server-meta.ts +++ b/packages/ui/src/lib/server-meta.ts @@ -6,6 +6,8 @@ let cachedMeta: ServerMeta | null = null let pendingMeta: Promise | null = null const [previewsUnavailable, setPreviewsUnavailable] = createSignal(false) const [previewsAvailabilityResolved, setPreviewsAvailabilityResolved] = createSignal(false) +const [localRequest, setLocalRequest] = createSignal(false) +export { localRequest } /** Whether this client reaches the server through Remote Control while its host keeps web previews off. */ export { previewsUnavailable } @@ -21,6 +23,7 @@ export async function getServerMeta(forceRefresh = false): Promise { } const request = serverApi.fetchServerMeta().then((meta) => { cachedMeta = meta + setLocalRequest(meta.localRequest === true) setPreviewsUnavailable(meta.previewsUnavailable === true) setPreviewsAvailabilityResolved(true) return meta diff --git a/packages/ui/tests/browser/fixtures/host-only-controls.tsx b/packages/ui/tests/browser/fixtures/host-only-controls.tsx new file mode 100644 index 0000000000..786d5e3182 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/host-only-controls.tsx @@ -0,0 +1,34 @@ +import "./remote-server-pairing-api" +import { render } from "solid-js/web" +import { serverApi } from "../../../src/lib/api-client" +import { ConfigProvider } from "../../../src/stores/preferences" +import { I18nProvider } from "../../../src/lib/i18n" +import { ThemeProvider } from "../../../src/lib/theme" +import FolderSelectionView from "../../../src/components/folder-selection-view" +import { SavedRemoteServersCard } from "../../../src/components/settings/saved-remote-servers-card" +import { RemoteAccessSettingsSection } from "../../../src/components/settings/remote-access-settings-section" +import { SideCarsSettingsSection } from "../../../src/components/settings/sidecars-settings-section" +import { SideCarPickerDialog } from "../../../src/components/sidecar-picker-dialog" +import { getServerMeta } from "../../../src/lib/server-meta" +import "../../../src/index.css" + +const query = new URLSearchParams(location.search) +serverApi.fetchServerMeta = (async () => { + const mode = query.get("mode") + if (mode === "failure") throw new Error("Unavailable") + const meta = { localUrl: "http://localhost:3000", listeningMode: "all", addresses: [] } + return mode === "missing" ? meta : { ...meta, localRequest: mode === "local" } +}) as typeof serverApi.fetchServerMeta +serverApi.fetchAuthStatus = async () => ({ authenticated: true, username: "codenomad", passwordUserProvided: true }) +serverApi.fetchSidecars = async () => ({ sidecars: [{ id: "fixture", name: "Fixture SideCar", kind: "port", port: 3000, + insecure: true, prefixMode: "strip", status: "running" }] }) as Awaited> + +function Harness() { + const surface = query.get("surface") + void getServerMeta().catch(() => {}).finally(() => { document.documentElement.dataset.metaSettled = "true" }) + if (surface === "picker") return {}} onOpenSidecar={() => {}} /> + if (surface === "settings") return <> + return {}} onSelectExistingInstance={() => {}} onOpenSidecar={() => {}} /> +} + +render(() => , document.getElementById("root")!) diff --git a/packages/ui/tests/browser/host-only-controls.test.ts b/packages/ui/tests/browser/host-only-controls.test.ts new file mode 100644 index 0000000000..6198af421d --- /dev/null +++ b/packages/ui/tests/browser/host-only-controls.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { chromium, type Browser } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" +import { createFixtureCache } from "./fixture-cache" +import { createFixtureShutdown } from "./fixture-shutdown" +import { armFixtureNavigationBudget } from "./fixture-navigation-budget" + +let server: ViteDevServer, browser: Browser, url: string +before(async () => { + const cache = await createFixtureCache(), shutdown = createFixtureShutdown(cache) + server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cache.cacheDir, + plugins: [shutdown.plugin, solid(), { name: "host-only-controls", configureServer(s) { s.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/fixture", `
`)) + }) } }], resolve: { dedupe: ["solid-js"] }, + optimizeDeps: { entries: ["tests/browser/fixtures/host-only-controls.tsx"], exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } }).catch(async error => { await cache.dispose(); throw error }) + shutdown.own(server) + await server.listen() + await server.transformRequest("/tests/browser/fixtures/host-only-controls.tsx") + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) + armFixtureNavigationBudget(browser) +}) +after(async () => { await browser?.close(); await server?.close() }) + +for (const mode of ["local", "nonlocal", "missing", "failure"]) { + for (const surface of ["home", "settings", "picker"]) { + test(`${surface}: host-only controls require affirmative local metadata (${mode})`, async () => { + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 }, locale: "en-US" }) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + try { + await page.goto(`${url}?mode=${mode}&surface=${surface}`) + await page.locator("html[data-meta-settled=true]").waitFor() + const local = mode === "local" + if (surface === "home") { + assert.equal(await page.getByRole("button", { name: "Open SideCar", exact: true }).count(), Number(local)) + assert.equal(await page.getByRole("button", { name: "Connect CodeNomad Server", exact: true }).count(), Number(local)) + } else if (surface === "settings") { + assert.equal(await page.getByRole("button", { name: "Connect CodeNomad Server", exact: true }).count(), Number(local)) + // Registration stays usable through paired and LAN connections. + assert.equal(await page.getByRole("button", { name: "Add SideCar", exact: true }).count(), 1) + assert.equal(await page.getByRole("button", { name: "Change password", exact: true }).count(), Number(local)) + if (local) { + await page.getByRole("button", { name: "Change password", exact: true }).click() + assert.equal(await page.locator('input[type="password"]').count(), 2) + } else { + assert.equal(await page.locator('input[type="password"]').count(), 0) + } + } else { + assert.equal(await page.getByRole("dialog").count(), Number(local)) + if (local) await page.getByRole("button", { name: /Fixture SideCar/ }).waitFor() + } + assert.deepEqual(errors, []) + } finally { await page.close() } + }) + } +} From 02f13ee869240147281e093bd4797ef82121155b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 03:41:30 +0100 Subject: [PATCH 2/4] test(ui): align local access browser fixtures Use the shared browser fixture daemon for host-only controls and provide explicit local server metadata in the launcher fixture. Preserve the non-local, missing and failed metadata assertions. Wait for the asynchronous native help opener receipt before checking its destination. The same race reproduces on the public base; all expected links and interaction assertions remain unchanged. Validate the complete affected browser files and fixture wiring, 91 security cases, help coverage and server/UI/Electron typechecks. --- dev-docs/BROWSER_TESTING.md | 4 +++ .../tests/browser/host-only-controls.test.ts | 25 +++++-------------- .../browser/new-instance-launcher.test.ts | 10 +++++--- .../tests/browser/settings-info-tip.test.ts | 2 ++ 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index 63b35dac6d..5bec953bab 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -84,6 +84,10 @@ synthetic provider. checker's failure text is the review). - Fixture names must stay free of route fragments: borrowed URLs are namespaced `/` and tests derive sibling URLs by substitution. +- Fixtures exercising host-local actions serve valid `/api/meta` data with + `localRequest: true`; non-local, missing and failed metadata retain their + fail-closed coverage in `host-only-controls.test.ts`. Native help-opener + assertions await the opener receipt after browser activation. - Tests that need stateful middleware (SSE feeds driven by the test, custom Vite transform hooks, configs that diverge from the canonical daemon config) keep their own server and live in the heavy tier. diff --git a/packages/ui/tests/browser/host-only-controls.test.ts b/packages/ui/tests/browser/host-only-controls.test.ts index 6198af421d..0dd400576f 100644 --- a/packages/ui/tests/browser/host-only-controls.test.ts +++ b/packages/ui/tests/browser/host-only-controls.test.ts @@ -1,31 +1,18 @@ import assert from "node:assert/strict" import { after, before, test } from "node:test" -import { fileURLToPath } from "node:url" import { chromium, type Browser } from "playwright" -import { createServer, type ViteDevServer } from "vite" -import solid from "vite-plugin-solid" -import { createFixtureCache } from "./fixture-cache" -import { createFixtureShutdown } from "./fixture-shutdown" +import { borrowFixture } from "./fixture-borrow" import { armFixtureNavigationBudget } from "./fixture-navigation-budget" -let server: ViteDevServer, browser: Browser, url: string +let browser: Browser, url: string before(async () => { - const cache = await createFixtureCache(), shutdown = createFixtureShutdown(cache) - server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cache.cacheDir, - plugins: [shutdown.plugin, solid(), { name: "host-only-controls", configureServer(s) { s.middlewares.use("/fixture", async (_req, res) => { - res.setHeader("Content-Type", "text/html") - res.end(await s.transformIndexHtml("/fixture", `
`)) - }) } }], resolve: { dedupe: ["solid-js"] }, - optimizeDeps: { entries: ["tests/browser/fixtures/host-only-controls.tsx"], exclude: ["lucide-solid"] }, - server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } }).catch(async error => { await cache.dispose(); throw error }) - shutdown.own(server) - await server.listen() - await server.transformRequest("/tests/browser/fixtures/host-only-controls.tsx") - url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture` + const fixture = await borrowFixture({ name: "host-only-controls", routes: [{ path: "/fixture", + html: '
' }] }) + url = fixture.url browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) armFixtureNavigationBudget(browser) }) -after(async () => { await browser?.close(); await server?.close() }) +after(async () => { await browser?.close() }) for (const mode of ["local", "nonlocal", "missing", "failure"]) { for (const surface of ["home", "settings", "picker"]) { diff --git a/packages/ui/tests/browser/new-instance-launcher.test.ts b/packages/ui/tests/browser/new-instance-launcher.test.ts index 01b24f2911..ddb2e53596 100644 --- a/packages/ui/tests/browser/new-instance-launcher.test.ts +++ b/packages/ui/tests/browser/new-instance-launcher.test.ts @@ -1,12 +1,11 @@ import assert from "node:assert/strict" import { after, before, test } from "node:test" -import { fileURLToPath } from "node:url" +import type { ServerMeta } from "../../../server/src/api-types" import { borrowFixture } from "./fixture-borrow" import { chromium, type Browser, type Page } from "playwright" import { armFixtureNavigationBudget } from "./fixture-navigation-budget" let browser: Browser, url: string -let cache: Awaited> before(async () => { const fixture = await borrowFixture({ name: "new-instance-launcher", @@ -18,7 +17,7 @@ before(async () => { browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) armFixtureNavigationBudget(browser) }) -after(async () => { await browser?.close(); await cache?.dispose() }) +after(async () => { await browser?.close() }) async function prepare(width = 1100, home = false) { const page = await browser.newPage({ viewport: { width, height: 760 }, locale: "en-US" }) @@ -33,7 +32,10 @@ async function prepare(width = 1100, home = false) { (window as any).__CODENOMAD_RUNTIME_HOST__ = "electron" window.__CODENOMAD_WINDOW_CONTEXT__ = "local" }) - await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" })) + await page.route("**/api/**", route => route.fulfill({ json: new URL(route.request().url()).pathname === "/api/meta" ? { + localRequest: true, localUrl: new URL(url).origin, eventsUrl: "/api/events", host: "127.0.0.1", + listeningMode: "local", localPort: Number(new URL(url).port), hostLabel: "Fixture", workspaceRoot: "D:/", addresses: [], + } satisfies ServerMeta : {} })) await page.goto(home ? `${url}?home` : url) await page.waitForFunction(() => Boolean((window as any).launcherFixture)) return { page, errors, remoteRequests, calls: () => page.evaluate(() => [...(window as any).launcherFixture.calls]) } diff --git a/packages/ui/tests/browser/settings-info-tip.test.ts b/packages/ui/tests/browser/settings-info-tip.test.ts index 678b754fa7..6230f401c7 100644 --- a/packages/ui/tests/browser/settings-info-tip.test.ts +++ b/packages/ui/tests/browser/settings-info-tip.test.ts @@ -66,6 +66,8 @@ for (const [host, context] of [["web", "remote"], ["electron", "local"], ["tauri assert.equal(await page.locator(".settings-choice-row input").isChecked(), false) assert.deepEqual(await page.evaluate(() => [(window as any).fixture.changed(), (window as any).fixture.leaked()]), [0, 0]) await page.locator("summary [role=link]").click() + // The native opener completes asynchronously after the browser click. + await page.waitForFunction(() => (window as any).opened.length === 3) assert.equal(await page.locator("details").getAttribute("open"), null) assert.equal((await opened(page)).at(-1), `${base}en/opencode.html#session-environment`) assert.equal(page.url(), url) From c995552e70fb4f08d50f8732b95de859d87f44de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 04:24:17 +0100 Subject: [PATCH 3/4] test(ui): keep baseline help readiness repair separate Leave the native help opener synchronization to its existing baseline repair PR. Keep this branch limited to the shared host-only fixture daemon and explicit local launcher metadata, with the baseline failure visible. --- dev-docs/BROWSER_TESTING.md | 3 +-- packages/ui/tests/browser/settings-info-tip.test.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index 5bec953bab..dad9505638 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -86,8 +86,7 @@ synthetic provider. namespaced `/` and tests derive sibling URLs by substitution. - Fixtures exercising host-local actions serve valid `/api/meta` data with `localRequest: true`; non-local, missing and failed metadata retain their - fail-closed coverage in `host-only-controls.test.ts`. Native help-opener - assertions await the opener receipt after browser activation. + fail-closed coverage in `host-only-controls.test.ts`. - Tests that need stateful middleware (SSE feeds driven by the test, custom Vite transform hooks, configs that diverge from the canonical daemon config) keep their own server and live in the heavy tier. diff --git a/packages/ui/tests/browser/settings-info-tip.test.ts b/packages/ui/tests/browser/settings-info-tip.test.ts index 6230f401c7..678b754fa7 100644 --- a/packages/ui/tests/browser/settings-info-tip.test.ts +++ b/packages/ui/tests/browser/settings-info-tip.test.ts @@ -66,8 +66,6 @@ for (const [host, context] of [["web", "remote"], ["electron", "local"], ["tauri assert.equal(await page.locator(".settings-choice-row input").isChecked(), false) assert.deepEqual(await page.evaluate(() => [(window as any).fixture.changed(), (window as any).fixture.leaked()]), [0, 0]) await page.locator("summary [role=link]").click() - // The native opener completes asynchronously after the browser click. - await page.waitForFunction(() => (window as any).opened.length === 3) assert.equal(await page.locator("details").getAttribute("open"), null) assert.equal((await opened(page)).at(-1), `${base}en/opencode.html#session-environment`) assert.equal(page.url(), url) From afb14397942a79f956ade05f307f011294f55969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 9 Nov 2026 05:30:59 +0100 Subject: [PATCH 4/4] test(ui): resolve Mission rows by business key across remounts Keep selection and expansion on live Playwright Locators keyed by data-mission-key so catalogue refreshes and retitling cannot reuse detached row handles. Preserve ordinary clicks, leaf detection and the existing selection/detail deadlines. Add deterministic real-panel remount, retitle and leaf coverage. The original helper fails the two expandable remount scenarios; the keyed helper passes all three. Document the shared browser-helper contract. UI typecheck passes; full caller validation is recorded separately. --- dev-docs/BROWSER_TESTING.md | 3 ++ packages/ui/tests/browser/mission-actions.ts | 27 ++++++++++------ .../browser/mission-status-marks.test.ts | 32 +++++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/dev-docs/BROWSER_TESTING.md b/dev-docs/BROWSER_TESTING.md index dad9505638..a5e10d4d01 100644 --- a/dev-docs/BROWSER_TESTING.md +++ b/dev-docs/BROWSER_TESTING.md @@ -95,6 +95,9 @@ synthetic provider. explicitly. Mutation collectors match the exact HTTP method and path. Hold the intended panel source independently of schedule reads, and await lifecycle feedback settlement before asserting its final status. +- Mission selection helpers retain `data-mission-key` and resolve row/chevron + Locators at each action across retitling and panel remounts. Wait for the row's + visibility before distinguishing a leaf from an expandable row; use native clicks. - Before pointer/keyboard selection in a Monaco diff, wait for its computed diff decoration (`.line-insert` in the Git fixture), then locate the line. Plain model text can paint before the worker result inserts deleted-code view diff --git a/packages/ui/tests/browser/mission-actions.ts b/packages/ui/tests/browser/mission-actions.ts index d7d6902fc2..6aeb93a3eb 100644 --- a/packages/ui/tests/browser/mission-actions.ts +++ b/packages/ui/tests/browser/mission-actions.ts @@ -1,4 +1,4 @@ -import type { ElementHandle, Locator, Page } from "playwright" +import type { Locator, Page } from "playwright" /** Exercise a `MissionListItem` action through its inline or measured-overflow presentation * (cleanup receipts and other shared list rows; the Mission picker has no row menus). */ @@ -60,12 +60,12 @@ export async function selectMission(page: Page, title: string | RegExp): Promise const entry = await inlineMissionEntry(page, title) // Often the first wait after navigation: allow for a cold page boot late in a long serial run. await entry.waitFor({ timeout: 60_000 }) - // Held as an element: rows are keyed, but a refresh may retitle the row once it is selected. - const row = (await entry.locator("xpath=..").elementHandle())! - if (await entry.getAttribute("aria-current") !== "true") await entry.click() + const row = await currentMissionRow(entry) + const select = row.locator(".mission-row-select") + if (await select.getAttribute("aria-current") !== "true") await select.click() // Like the session list, a click only selects: its chevron expands the row, showing its detail. // A Mission whose plan holds no task has no chevron: its (empty) detail stays folded. - const leaf = await expandRowElement(row) === "leaf" + const leaf = await expandMissionRow(row) === "leaf" const detail = missionDetail(page) await detail.waitFor({ state: leaf ? "attached" : "visible" }) return detail @@ -75,13 +75,20 @@ export async function selectMission(page: Page, title: string | RegExp): Promise * "leaf" when the row has no chevron (a Mission without tasks). */ export async function expandRow(select: Locator): Promise<"expanded" | "leaf"> { await select.waitFor({ state: "attached" }) - return expandRowElement((await select.locator("xpath=..").elementHandle())!) + return expandMissionRow(await currentMissionRow(select)) } -/** `expandRow` on a row's `.session-item-base` element. */ -async function expandRowElement(row: ElementHandle): Promise<"expanded" | "leaf"> { - const chevron = await row.$(".session-item-expander:not(.session-item-expander--spacer)") - if (!chevron) return "leaf" +/** Keep business identity across a title change or panel remount, resolving DOM at each action. */ +async function currentMissionRow(select: Locator): Promise { + const key = await select.locator("xpath=ancestor::*[@data-mission-key]").getAttribute("data-mission-key") + if (!key) throw new Error("Mission row has no business key") + return missionRows(select.page()).locator(`.mission-row[data-mission-key=${JSON.stringify(key)}]`) +} + +async function expandMissionRow(row: Locator): Promise<"expanded" | "leaf"> { + await row.waitFor({ state: "visible" }) + const chevron = row.locator(".session-item-expander:not(.session-item-expander--spacer)") + if (!await chevron.count()) return "leaf" if (await chevron.getAttribute("aria-expanded") !== "true") await chevron.click() return "expanded" } diff --git a/packages/ui/tests/browser/mission-status-marks.test.ts b/packages/ui/tests/browser/mission-status-marks.test.ts index 77a3ceab2d..529f30ccbd 100644 --- a/packages/ui/tests/browser/mission-status-marks.test.ts +++ b/packages/ui/tests/browser/mission-status-marks.test.ts @@ -85,6 +85,38 @@ async function refresh(page: Page) { } const mark = async (page: Page, title: string) => (await inlineMissionEntry(page, title)).locator(".mission-row-flag").getAttribute("data-mark") +for (const variant of ["remount", "retitle", "leaf"] as const) test(`selection resolves the current business row after ${variant}`, async () => { + let value = mission("selection", variant === "leaf" ? { tasks: [] } : {}) + const { page, errors } = await open(() => [value], () => []) + try { + const entry = await inlineMissionEntry(page, value.objective) + await entry.waitFor() + const original = await entry.elementHandle() + if (variant === "retitle") value = { ...value, objective: "Renamed selection", revision: 2 } + // Replace the real Solid panel at the selection/expansion boundary. The HTTP + // refresh can also rename the business row while the helper still has its old title. + await page.evaluate(`(async () => { + const { missionStore } = await import("/src/stores/missions.ts") + document.addEventListener("click", async function remount(event) { + if (!event.target.closest(".mission-row-select")) return + document.removeEventListener("click", remount) + window.selectionClickTrusted = event.isTrusted + const fixture = window.missionFixture + fixture.mount(false) + await missionStore.refresh("fixture") + fixture.mount(true) + }) + })()`) + const detail = await selectMission(page, "Objective selection") + assert.equal(await original!.evaluate(element => element.isConnected), false, "the original row really detached") + assert.equal(await page.evaluate(() => (window as any).selectionClickTrusted), true) + assert.equal(await selectedMissionTitle(page).innerText(), value.objective) + assert.equal(await detail.isVisible(), variant !== "leaf") + assert.equal(await page.locator('.mission-row[data-mission-key="mission:selection"] .session-item-expander:not(.session-item-expander--spacer)').count(), variant === "leaf" ? 0 : 1) + assert.deepEqual(errors, []) + } finally { await page.close() } +}) + test("marks follow the session palette and a completion stays green until this window shows it", async () => { let list = [ mission("stuck", { updatedAt: 6 }),