diff --git a/.gitignore b/.gitignore index 94b8fe3..4d4f5d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ # Virtual Environments tools/.venv*/ tools/python-embed/ +tools/launcher-settings.json +tools/.launcher-settings-* tools/.ms-playwright/ .venv/ .venv_wsl/ venv/ # Python Cache +desktop/node_modules/ +desktop/dist/ __pycache__/ *.pyc *.pyo diff --git a/README.md b/README.md index 2877108..3b88071 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ pulling large changes. ## What It Does +An optional [Electron desktop evaluation](desktop/README.md) can launch its own +backend and open a standalone window without manual token entry. It retains +Core browser settings and keys per origin and backend mode, and is intended +for local evaluation. About shows Desktop and Core versions separately; +the Core version is maintained in `core_version.py`. + - Runs SSH, Local Shell, and UART sessions inside browser terminal tabs. - Supports multiple persistent terminal tabs while the server process is alive. - Provides StandTerm Files for direct SSH and supported Local Shell sessions, @@ -360,6 +366,45 @@ To authorize a browser from the WSL IP URL: Accepted browser keys are stored in `authorized/browsers.json`. Delete that file or remove an entry to revoke access. +### Platform Session Recovery + +StandTerm can register a platform passkey backed by Windows Hello, Touch ID, or +another browser-supported platform authenticator. The passkey restores the +`HttpOnly` cookie for the live backend session for which it was most recently +armed after a browser loses its cookie; it does not expose or persist the +access token or session token. + +WebAuthn requires a hostname-based relying-party ID. An IP URL such as +`https://172.x.x.x:5000` cannot register or use platform recovery. On the same +Windows or macOS host, open the launcher-provided `localhost` Access URL +instead, such as `https://localhost:5000` for the default WSL setup or +`http://localhost:5000` for a native loopback-only server. For access from +another device, use a stable hostname with trusted HTTPS. + +To enable recovery: + +1. Sign in through the stable hostname Access URL. +2. Open **Settings > Server > Platform session recovery**. +3. Select **Register platform passkey** and complete the system verification + prompt. +4. If a later backend process must be authorized again with the access token, + select **Arm existing passkey** before relying on recovery for that live + process. + +When the session cookie is missing, select **Recover live session with device** +on the Access Required page or in the in-app recovery prompt. Recovery succeeds +only while that session remains active in the same `app.py` process. A backend +restart, expired session, closed terminal bridge, or disconnected remote host +cannot be reconstructed by the passkey. + +Credential IDs, public keys, counters, and non-secret authenticator metadata are +stored separately in `authorized/session_recovery_credentials.json`. Platform +private keys remain in the authenticator. Use **Revoke recovery** to remove the +server-side credential records; the operating system may retain its passkey. +Synced platform passkeys may be available on other devices, so the feature is +described as platform recovery rather than a guaranteed hardware-bound device +identity. + For multiple Windows browsers connecting to WSL, open the full Access URL printed by `run.sh` in each browser, including `?token=...`. Copying the post-redirect `/` URL from one browser to another does not carry access. @@ -599,13 +644,20 @@ Each example directory includes `skill_prompt.txt` for installing the skill and the intended installation prompt shape is: ```text -Read docs/examples/standterm-external-agent-skill/SKILL.md and add the standterm-external-agent local skill. +Install docs/examples/standterm-external-agent-skill/ as the standterm-external-agent local skill, including SKILL.md and references/ with relative paths intact. ``` Use the matching workflow `boot_prompt.txt` together with the installed `standterm-external-agent` skill. Workflow skills do not duplicate handoff, token, TLS, or terminal I/O mechanics. +The external-agent entrypoint covers routine low-output operations. Load its +connection, terminal-workflow or persistent-client references only when needed; +do not flatten the references into the installed entrypoint. This reorganizes +usage guidance without changing helper/API behavior or authorization. CLI/MCP +tail cursors still need explicit continuation, and compact shell output is not +a reliable command exit status or a complete approval/paging response. + The skill tells an agent to: - fetch fresh tokenless agentinfo from the startup banner's URL before using @@ -631,15 +683,61 @@ after the skill exists, paste `boot_prompt.txt` into the assisting agent. ## Configuration +Shortcut launchers (`run.sh`, `run.bat`, and their WSL wrappers) load a saved +port from `tools/launcher-settings.json`, next to the platform venvs. An explicit +`STANDTERM_PORT` overrides this setting. Without a saved setting or override, +the launcher selects an automatic port and remembers it after binding succeeds. +It does not default to `5000`, +which may be needed by another service. Existing saved ports (including `5000`) +are preserved rather than silently changing the browser origin. +When a port is occupied, an interactive launch suggests an automatic candidate and +asks before retrying. After binding successfully, it offers to remember the new +port. The local, Git-ignored settings file stores only its format version and +port, never authentication data, and survives venv recreation. It is shared by +Windows and WSL shortcuts using the same checkout; it does not merge their Core +instances. Direct `app.py` execution retains its `5000` default and does not load +this file. Desktop follows the same first-allocation/reuse policy using separate +per-mode settings in its own user-data directory. + +On a port conflict, non-interactive launches fail with a suggested `STANDTERM_PORT` instead of +waiting for input or silently changing ports. No existing service is stopped or +automatically reused. Browser opening and access URL publication happen only +after the listener is bound. Changing ports changes the browser origin, so +existing browser preferences and SSH keys are not automatically migrated. + +Automatic selection (including conflict suggestions) uses the IANA +Dynamic/Private range `49152–65535`, excludes built-in known fixed TCP uses and +TCP entries in the backend OS services file, and then attempts actual binding. +Linux/WSL/macOS use `/etc/services`; native Windows uses +`%SystemRoot%\System32\drivers\etc\services`. If the file cannot be read, Python +emits a warning and selection still uses the private range and built-in list. +No services file is modified, and startup performs no online lookup. Selection +tries at most 20 distinct candidates; exhaustion fails without saving a port. +The final listener stays bound through startup notification, preventing a +probe-close-rebind race on first launch. Conflict suggestions remain provisional +and are checked again when bound after operator approval. + +The offline policy in `server_startup.py` records its sources and review date: +[IANA's range definitions](https://www.iana.org/assignments/service-names-port-numbers/) +avoid the assigned-port space without bundling the entire registry; +[Apple's documented fixed TCP uses](https://support.apple.com/en-us/103229) +add `5000`, `6000`, `7000` and `62078` to the built-in exclusions (reviewed +2026-09-07). Broad dynamic-use ranges in vendor documentation are not treated +as fixed reservations. This reduces conflicts; it cannot reserve future +availability or account for every unregistered application. Explicit/saved ports +are not silently filtered, changed or migrated by this automatic-selection policy. + Common settings: | Setting | Purpose | | --- | --- | | `STANDTERM_HOST` | Bind host used by the launcher when set. | -| `STANDTERM_PORT` | Default port, usually `5000`. | +| `STANDTERM_PORT` | Explicit port override (1–65535); takes precedence over saved launcher settings. Shortcuts allocate and save a port on first launch; direct `app.py` defaults to `5000`. | +| `STANDTERM_OPEN_BROWSER=0` | Disable automatic browser opening from the shortcut launchers. | | `STANDTERM_HTTPS=1` | Force HTTPS. | | `STANDTERM_DISABLE_AUTO_HTTPS=1` | Disable automatic HTTPS for non-loopback binds. | | `STANDTERM_CERTS_DIR` | Override local certificate storage. | +| `STANDTERM_SESSION_RECOVERY_STORE` | Override the platform session-recovery public credential store. | | `STANDTERM_ALLOW_REMOTE_SSH=1` | Acknowledge SSH while listening on a non-loopback address. | | `STANDTERM_ALLOW_REMOTE_LOCAL_SHELL=1` | Acknowledge Local Shell while listening on a non-loopback address. | | `STANDTERM_ALLOW_REMOTE_UART=1` | Acknowledge UART while listening on a non-loopback address. | diff --git a/app.py b/app.py index 57cc6cb..15d7c3e 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,7 @@ import tempfile from collections import deque from pathlib import Path +from core_version import CORE_VERSION from flask import Flask, Response, render_template, request, abort, make_response, redirect, send_file, jsonify, stream_with_context from flask_socketio import SocketIO, ConnectionRefusedError from external_agent_dispatch import ExternalAgentCommandDispatcher @@ -75,6 +76,11 @@ UARTBridge, ) from runtime_logging import log_message +from session_recovery import ( + SessionRecoveryCredentialStore, + SessionRecoveryError, + SessionRecoveryService, +) paramiko = None serial_module = None @@ -129,7 +135,10 @@ def get_prefixed_env_name(name): SSH_PORT = 22 SSH_USER = os.getenv('USER', 'aska') DEFAULT_BIND_HOST = get_prefixed_env('HOST').strip() -DEFAULT_PORT = int(get_prefixed_env('PORT', '5000')) +try: + DEFAULT_PORT = int(get_prefixed_env('PORT', '5000')) +except ValueError: + raise SystemExit('STANDTERM_PORT must be an integer from 1 to 65535.') from None AGENT_EXTERNAL_DEV_TOKEN_ENABLED = is_prefixed_env_enabled('AGENT_DEV_TOKEN') def parse_optional_seconds_env(name, default=None): @@ -655,6 +664,13 @@ def resolve_external_agent_runtime_root(platform_name=None, env=None, home=None, EXTERNAL_AGENT_INFO_PATH = EXTERNAL_AGENT_INSTANCE_DIR / 'standterm_agentinfo.json' AUTHORIZED_DIR = APP_DIR / 'authorized' AUTHORIZED_BROWSERS_PATH = AUTHORIZED_DIR / 'browsers.json' +SESSION_RECOVERY_CREDENTIALS_PATH = Path( + get_prefixed_env('SESSION_RECOVERY_STORE').strip() + or AUTHORIZED_DIR / 'session_recovery_credentials.json' +).expanduser() +session_recovery_service = SessionRecoveryService( + SessionRecoveryCredentialStore(SESSION_RECOVERY_CREDENTIALS_PATH) +) def resolve_external_agent_current_info_path(runtime_root=None, env=None): env = os.environ if env is None else env @@ -5449,6 +5465,7 @@ def is_valid_session(session_token): return False if time.time() > expires_at: active_sessions.pop(session_token, None) + session_recovery_service.unbind_session(session_token) close_all_terminal_bridges(session_token) agent_session_ids.pop(session_token, None) return False @@ -5463,6 +5480,7 @@ def cleanup_expired_sessions(): ] for session_token in expired_tokens: active_sessions.pop(session_token, None) + session_recovery_service.unbind_session(session_token) close_all_terminal_bridges(session_token) for sid, sid_session_token in list(socket_session_tokens.items()): if sid_session_token == session_token: @@ -5554,6 +5572,20 @@ def build_access_required_response(): cursor: pointer; font-weight: 700; } + button.secondary { + margin-top: 10px; + background: #2c2c2e; + border: 1px solid #4a4a4f; + } + .divider { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 6px; + color: #777; + font-size: 0.8rem; + } + .divider::before, .divider::after { content: ""; height: 1px; flex: 1; background: #3a3a3c; } .hint { margin-top: 14px; font-size: 0.85rem; color: #8e8e93; } #access-login-status { min-height: 18px; color: #ff9f0a; } @@ -5567,7 +5599,10 @@ def build_access_required_response(): +
Device recovery uses Windows Hello, Touch ID, or another platform passkey previously registered for this hostname. It only restores a session still running in this StandTerm process.
For Windows browsers connecting to a WSL IP over HTTPS, the browser may also require trusting the StandTerm local CA.