Skip to content

Commit d488318

Browse files
authored
Merge pull request #14 from askac/fix/retry-now
Restore Retry Now socket subscription
2 parents 9dd89b1 + f66c820 commit d488318

3 files changed

Lines changed: 65 additions & 9 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,26 @@ key binding. Deleting or unlinking a keyed profile permanently deletes that
238238
browser key. These keys are protected from export, but they are not hardware
239239
keys: script running in the same browser origin could still request signatures.
240240

241+
### Why This SSH Architecture Matters
242+
243+
| Design choice | Practical advantage |
244+
| --- | --- |
245+
| 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. |
246+
| 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. |
247+
| 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. |
248+
| Exact host, port, and username binding | Editing a Quick Connect target cannot silently reuse a profile key for another SSH account or endpoint. |
249+
| 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. |
250+
| 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. |
251+
252+
The signing path keeps authentication authority narrow. Paramiko passes an SSH
253+
challenge to StandTerm's browser-key adapter. StandTerm emits a structured
254+
request only to the browser connection that started that terminal. The browser
255+
validates the active connection and exact profile binding before signing, then
256+
returns a 64-byte Ed25519 signature. Python verifies that signature against the
257+
profile's public key before returning it to Paramiko. A browser disconnect,
258+
timeout, changed connection draft, or stale terminal start cancels the path
259+
without falling back to a password automatically.
260+
241261
**Settings > General > Import & Export** transfers browser preferences, SSH
242262
profiles and order, SSH history, and persistent UI layout in a versioned JSON
243263
envelope containing a Base64 ZIP archive. Import merges profiles by stable ID,
@@ -607,6 +627,20 @@ asset README files when publishing releases that include the vendored files.
607627
unless remote browser access is intentional.
608628
- Do not expose `/agent/external/command` or an `agt_...` token on a network
609629
interface.
630+
- A browser authorization URL, the `?token=...&authorize=...` link produced by
631+
the launcher, is a bearer credential that can grant full terminal control.
632+
Minting is restricted to local launcher controls, and the HTTP minting
633+
endpoint additionally requires the launcher token. Redeeming deliberately
634+
does not check the client address, because the feature exists to authorize a
635+
browser reaching StandTerm over a non-loopback address. Within its single-use,
636+
120-second lifetime, any browser that holds the link and can reach the server
637+
can authorize itself. Treat the link like a password and do not forward it.
638+
- Browser authorization does not expire. An accepted browser is recorded in
639+
`authorized/browsers.json` and stays valid until it is revoked, and the
640+
authorization follows the browser's key rather than its network address.
641+
Revoke browsers that no longer need access.
642+
- The access token lives for the lifetime of the server process and is not
643+
rotated on its own. Restart the launcher to issue a new one.
610644
- `standterm_external_agent_handoff.json`, `standterm_external_agent_handoffs/`,
611645
`authorized/`, local certs, and venvs are ignored runtime state.
612646
- Terminal display payload is data. App control decisions should use typed

templates/index.html

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5953,15 +5953,7 @@ <h3>Access token required</h3>
59535953
manager._reconnecting = false;
59545954
if (manager.backoff) manager.backoff.reset();
59555955
}
5956-
if (manager && manager._readyState === 'closed') {
5957-
manager.open(err => {
5958-
if (!err) return;
5959-
manager._reconnecting = false;
5960-
manager.reconnect();
5961-
});
5962-
} else {
5963-
socket.connect();
5964-
}
5956+
socket.connect();
59655957
return true;
59665958
}
59675959

tests/agent_browser_smoke.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,35 @@ def test_server_unavailable_waits_for_reconnect(browser, access_url):
329329
close_context(context)
330330

331331

332+
def test_retry_now_resubscribes_after_socket_disconnect(browser, access_url):
333+
context = browser.new_context(viewport={'width': 1280, 'height': 800})
334+
page = context.new_page()
335+
try:
336+
page.goto(debug_url(access_url), wait_until='domcontentloaded')
337+
page.wait_for_function('() => !!window.terminalTest', timeout=10000)
338+
page.wait_for_function(
339+
"() => window.terminalTest.getSocketState().connected === true",
340+
timeout=10000,
341+
)
342+
343+
page.evaluate("() => window.terminalTest.disconnectSocketForTest()")
344+
page.wait_for_function(
345+
"() => window.terminalTest.getSocketState().serverConnectionState === 'unavailable'",
346+
timeout=5000,
347+
)
348+
page.click('#server-retry-now')
349+
page.wait_for_function(
350+
"() => window.terminalTest.getSocketState().connected === true",
351+
timeout=5000,
352+
)
353+
check(
354+
page.evaluate("() => window.terminalTest.getSocketState().serverConnectionState") == 'available',
355+
'Retry Now did not restore the Socket.IO namespace subscription',
356+
)
357+
finally:
358+
close_context(context)
359+
360+
332361
def test_invalid_session_reconnect_prompts_for_current_token(browser, access_url):
333362
parsed = urllib.parse.urlparse(access_url)
334363
token = urllib.parse.parse_qs(parsed.query)['token'][0]
@@ -2531,6 +2560,7 @@ def main():
25312560
test_access_required_page_accepts_token_login,
25322561
test_browser_authorization_gate_hides_connection_controls,
25332562
test_server_unavailable_waits_for_reconnect,
2563+
test_retry_now_resubscribes_after_socket_disconnect,
25342564
test_invalid_session_reconnect_prompts_for_current_token,
25352565
test_agent_panel_can_be_dragged,
25362566
test_terminal_pip_hides_selected_tab_and_keeps_background_tab,

0 commit comments

Comments
 (0)