Skip to content

fix(terminal): derive stable write token so operate links survive worker restart#581

Open
47seek wants to merge 2 commits into
deepcoldy:masterfrom
47seek:fix/stable-terminal-write-token
Open

fix(terminal): derive stable write token so operate links survive worker restart#581
47seek wants to merge 2 commits into
deepcoldy:masterfrom
47seek:fix/stable-terminal-write-token

Conversation

@47seek

@47seek 47seek commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

The web-terminal 「🔑 操作链接」/write link (http://<host>:<port>/s/<sessionId>?token=...) starts returning 403 Forbidden after a while, even though the session is still alive and its read-only 「🖥️ 打开 Web 终端」link opens fine.

Root cause

The read-only view token was already migrated to a stable per-session HMAC (deriveTerminalViewToken), so read links survive restarts. But the write/operate token is still a per-process value minted at worker boot:

const writeToken = randomBytes(16).toString('hex');

A silent daemon restart re-forks every worker, regenerating that token — so every previously-issued write link immediately 403s.

Reproduced on a live session:

  • old ?token=…HTTP 403
  • freshly-derived stable viewToken on the same session → HTTP 200

i.e. the session is reachable; only the operate token went stale after the worker restart.

Fix

Derive the write token the same way as the view token — from the host-only dashboard secret — but with a distinct domain separator botmux-terminal-write-v1 so the two capabilities can never collide (knowing the read token must never yield the write token, and vice-versa). A restarted worker re-mints the same operate token, keeping already-issued write links valid.

  • Falls back to the random boot token when the secret is unavailable (standalone/test).
  • The host-only secret stays masked from sandboxed CLIs, so a sandboxed CLI still cannot mint its own write link.

Tests

test/terminal-write-auth.test.ts adds: write token is stable per session, domain/secret-bound, and distinct from the view token for the same session+secret.

  • npx tsc --noEmit
  • vitest run terminal-write-auth (31) + worker read-auth integration (1) all green ✅

…ker restart

The read-only view token was migrated to a stable per-session HMAC
(deriveTerminalViewToken), but the write/operate token was still a
per-process `randomBytes(16)` minted at worker boot. A silent daemon
restart re-forks every worker, regenerating that token — so any
already-issued 「操作链接」/write link (`?token=`) immediately 403s
("Forbidden") even though the session is alive and reachable via its
(stable) view link.

Fix: derive the write token the same way as the view token, from the
host-only dashboard secret, using a DISTINCT domain separator
(`botmux-terminal-write-v1`) so the two capabilities can never collide —
knowing the read token must never yield the write token. A restarted
worker now re-mints the same operate token, keeping previously-issued
write links valid. Falls back to the random boot token when the secret
is unavailable (standalone/test), and the secret stays masked from
sandboxed CLIs, so a sandboxed CLI still cannot mint its own write link.

Tests: write token is stable per session, domain/secret-bound, and
distinct from the view token for the same session+secret.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
@47seek
47seek requested a review from deepcoldy as a code owner July 24, 2026 02:12
Lock the worker init → refreshTerminalWriteToken → ready.token path so a
regression back to a per-process random write token (which 403s every
already-issued 操作链接 on worker restart) is caught. Mirrors the existing
ready.viewToken assertion.
@deepcoldy

Copy link
Copy Markdown
Owner

Follow-up: integration assertion for the stable write token (relay)

Per maintainer request, added the non-blocking test hardening discussed in review: the existing worker integration test already spawns a real worker with a deterministic host-only secret and asserts ready.viewToken === deriveTerminalViewToken(secret, sessionId). I added the parallel assertion for the operate/write capability:

expect(ready.token).toBe(deriveTerminalWriteToken(secret, sessionId));

This locks the real init → refreshTerminalWriteToken → ready.token path end-to-end (not just the pure HMAC helper), so any regression back to a per-process random write token — which is exactly what 403s every already-issued 操作链接 on a worker restart — is caught by CI.

Scope: single test file, +5/-1. No production code touched.

Verification (local, TZ=Asia/Shanghai):

  • npx vitest run test/worker-terminal-read-auth.integration.test.ts test/terminal-write-auth.test.ts32/32 passed
  • npx tsc --noEmit → clean

Commit: 2387ec5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants