From 573e8a60d28fb2785ff3fa8dec69155e56758b2e Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:38:40 +0800 Subject: [PATCH 1/3] Restore Retry Now socket subscription ## Why Retry Now opens the Socket.IO manager directly after some disconnect paths have removed the namespace subscriptions. The transport can reopen without restoring the page socket, leaving the unavailable state visible. ## What changed - Reconnect through the Socket so namespace subscriptions are restored before the manager opens. - Preserve continuous backoff and access-token recovery for failed retry attempts. ## Testing Covered offline automatic recovery, immediate recovery after a full Socket disconnect, current-token recovery, and the complete browser regression suite. --- templates/index.html | 10 +--------- tests/agent_browser_smoke.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/templates/index.html b/templates/index.html index 8ca4043..01c021e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5953,15 +5953,7 @@

Access token required

manager._reconnecting = false; if (manager.backoff) manager.backoff.reset(); } - if (manager && manager._readyState === 'closed') { - manager.open(err => { - if (!err) return; - manager._reconnecting = false; - manager.reconnect(); - }); - } else { - socket.connect(); - } + socket.connect(); return true; } diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 0f22354..087ac56 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -329,6 +329,35 @@ def test_server_unavailable_waits_for_reconnect(browser, access_url): close_context(context) +def test_retry_now_resubscribes_after_socket_disconnect(browser, access_url): + context = browser.new_context(viewport={'width': 1280, 'height': 800}) + page = context.new_page() + try: + page.goto(debug_url(access_url), wait_until='domcontentloaded') + page.wait_for_function('() => !!window.terminalTest', timeout=10000) + page.wait_for_function( + "() => window.terminalTest.getSocketState().connected === true", + timeout=10000, + ) + + page.evaluate("() => window.terminalTest.disconnectSocketForTest()") + page.wait_for_function( + "() => window.terminalTest.getSocketState().serverConnectionState === 'unavailable'", + timeout=5000, + ) + page.click('#server-retry-now') + page.wait_for_function( + "() => window.terminalTest.getSocketState().connected === true", + timeout=5000, + ) + check( + page.evaluate("() => window.terminalTest.getSocketState().serverConnectionState") == 'available', + 'Retry Now did not restore the Socket.IO namespace subscription', + ) + finally: + close_context(context) + + def test_invalid_session_reconnect_prompts_for_current_token(browser, access_url): parsed = urllib.parse.urlparse(access_url) token = urllib.parse.parse_qs(parsed.query)['token'][0] @@ -2531,6 +2560,7 @@ def main(): test_access_required_page_accepts_token_login, test_browser_authorization_gate_hides_connection_controls, test_server_unavailable_waits_for_reconnect, + test_retry_now_resubscribes_after_socket_disconnect, test_invalid_session_reconnect_prompts_for_current_token, test_agent_panel_can_be_dragged, test_terminal_pip_hides_selected_tab_and_keeps_background_tab, From 9fd6911dc5cf62c25a779d33caf92356eed6000c Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:38:53 +0800 Subject: [PATCH 2/3] Document browser-owned SSH key architecture --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 0842022..ac330be 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,26 @@ key binding. Deleting or unlinking a keyed profile permanently deletes that browser key. These keys are protected from export, but they are not hardware keys: script running in the same browser origin could still request signatures. +### Why This SSH Architecture Matters + +| Design choice | Practical advantage | +| --- | --- | +| Non-extractable browser-owned private key | Private key bytes do not cross the browser boundary or enter Python memory, configuration files, settings exports, or terminal payloads. | +| Explicit key creation and per-connection **Use key** control | A key exists only after the user opts in for a saved profile, and password or host-side authentication remains available when key use is off. | +| Typed, short-lived signing requests | Each request is bound to the initiating browser connection, terminal, profile, key, public-key fingerprint, and challenge hash. Expired, replayed, stale, or mismatched responses fail closed. | +| Exact host, port, and username binding | Editing a Quick Connect target cannot silently reuse a profile key for another SSH account or endpoint. | +| Standard OpenSSH Ed25519 public key | The remote host only needs the copied key in `authorized_keys`; it does not need StandTerm, a browser component, or an agent. | +| Separate settings and key stores | Profiles, history, and browser preferences remain portable while private keys and key identifiers stay local to the browser that created them. | + +The signing path keeps authentication authority narrow. Paramiko passes an SSH +challenge to StandTerm's browser-key adapter. StandTerm emits a structured +request only to the browser connection that started that terminal. The browser +validates the active connection and exact profile binding before signing, then +returns a 64-byte Ed25519 signature. Python verifies that signature against the +profile's public key before returning it to Paramiko. A browser disconnect, +timeout, changed connection draft, or stale terminal start cancels the path +without falling back to a password automatically. + **Settings > General > Import & Export** transfers browser preferences, SSH profiles and order, SSH history, and persistent UI layout in a versioned JSON envelope containing a Base64 ZIP archive. Import merges profiles by stable ID, From f66c8203670457ec66839c0d4c0f2db467995ed8 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:59:12 +0800 Subject: [PATCH 3/3] Clarify browser authorization credential scope ## Why Browser authorization setup documents the redemption flow but not the grant link's bearer scope, persistent browser trust, or process-lifetime access token. Forwarding one of these credentials can therefore grant more access than its holder expects. ## What changed - Document the local minting boundary and the authorization URL's single-use 120-second lifetime. - Explain that accepted browser keys remain authorized until revoked. - Preserve the existing process-scoped access token behavior while documenting its rotation condition. ## Testing Covered source-level verification of authorization grant expiry and replay rejection, local HTTP minting policy, persistent browser-key storage, and process-scoped access token generation. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index ac330be..114aaff 100644 --- a/README.md +++ b/README.md @@ -627,6 +627,20 @@ asset README files when publishing releases that include the vendored files. unless remote browser access is intentional. - Do not expose `/agent/external/command` or an `agt_...` token on a network interface. +- A browser authorization URL, the `?token=...&authorize=...` link produced by + the launcher, is a bearer credential that can grant full terminal control. + Minting is restricted to local launcher controls, and the HTTP minting + endpoint additionally requires the launcher token. Redeeming deliberately + does not check the client address, because the feature exists to authorize a + browser reaching StandTerm over a non-loopback address. Within its single-use, + 120-second lifetime, any browser that holds the link and can reach the server + can authorize itself. Treat the link like a password and do not forward it. +- Browser authorization does not expire. An accepted browser is recorded in + `authorized/browsers.json` and stays valid until it is revoked, and the + authorization follows the browser's key rather than its network address. + Revoke browsers that no longer need access. +- The access token lives for the lifetime of the server process and is not + rotated on its own. Restart the launcher to issue a new one. - `standterm_external_agent_handoff.json`, `standterm_external_agent_handoffs/`, `authorized/`, local certs, and venvs are ignored runtime state. - Terminal display payload is data. App control decisions should use typed