Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -607,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
Expand Down
10 changes: 1 addition & 9 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5953,15 +5953,7 @@ <h3>Access token required</h3>
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;
}

Expand Down
30 changes: 30 additions & 0 deletions tests/agent_browser_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
Loading