Skip to content

feat(ui): record web terminal sessions#6602

Merged
gustavosbarreto merged 3 commits into
masterfrom
feature/web-terminal-session-recording
Jul 6, 2026
Merged

feat(ui): record web terminal sessions#6602
gustavosbarreto merged 3 commits into
masterfrom
feature/web-terminal-session-recording

Conversation

@gustavosbarreto

@gustavosbarreto gustavosbarreto commented Jul 2, 2026

Copy link
Copy Markdown
Member

What

Adds session recording to the web terminal: sessions opened from the console can be captured and replayed later.

Recording

  • Local recording (Community): the web terminal streams the session to the browser (OPFS, asciicast) with no server storage, so self-hosted users get replay for free. Opt-in from the Connect drawer (on by default), skipped when the namespace already records server-side. Recordings are namespaced per signed-in user, so a shared browser profile never exposes one user's recording to another.
  • Advanced Session Recording (Cloud/Enterprise): the existing server-side recording, gated by the namespace session_record setting.

The web terminal also learns the server session uid over an SSH global request (session-uid@shellhub.io) so a local recording can be paired to its session.

Where recordings play

A recording is an attribute of a session, not a place of its own, so playback moved into the Sessions list and the standalone Recordings page is removed. Per shell session:

  • Play uses the local copy when present, otherwise the server recording.
  • On Community, a session with no recording opens an upsell for Advanced Session Recording; on Cloud and Enterprise it shows Play disabled.
  • Local playback needs no session:play permission (it is the user's own browser data, scoped to them); server playback still requires it.

Supporting changes

  • A sessions.web flag (migration 008) and a Web/SSH origin column, so a web-originated session is identifiable. The SSH server sets the flag for web-terminal connections.

Notes

  • Local retention moved out with the Recordings page; cleanup defaults to never and will get a dedicated settings page later.
  • Migration is numbered 008 against master; renumber if another migration lands there first.

@gustavosbarreto gustavosbarreto requested review from a team as code owners July 2, 2026 19:57
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 10m 22s —— View job


Code Review Complete

Reviewed 22 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

A few things the review specifically verified as sound:

  • Field plumbing — the new web flag is consistent across pkg/models, pkg/api/requests, the pg entity (both SessionFromModel/SessionToModel mappings), the OpenAPI schema (added to required), and the SSH register() path.
  • Migration 008up (ADD COLUMN IF NOT EXISTS ... NOT NULL DEFAULT false) and down (DROP COLUMN IF EXISTS) are exact inverses, idempotent, and correctly numbered.
  • Cross-repo (cloud/)cloud's entity.Session embeds coreentity.Session, so the added field is inherited with no cloud change and no scanning break.
  • SSH session-uid handshake — the session-uid@shellhub.io handler returns ctx.SessionID(), which matches the value used as the session UID, so local recordings pair to the correct server session. It only ever discloses the caller's own session id.
  • Playback / XSS — asciicast data is handed to asciinema-player's create({ data }) (parsed, not innerHTML); no dangerouslySetInnerHTML in the new/changed code. Local playback intentionally skips session:play (user's own browser data); server playback still routes through RestrictedAction.
  • Recorder lifecycle — the OPFS recorder's StrictMode-remount vs. real-close handling (stillOpen, ref-nulling idempotency) is sound given TerminalManager stays mounted in AppLayout.

To request another review round, comment /review.

@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from d6f2796 to 0daa47f Compare July 2, 2026 20:11
@gustavosbarreto gustavosbarreto changed the title feat(ui): record web terminal sessions and play them from Sessions feat(ui): record web terminal sessions Jul 2, 2026
@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@gustavosbarreto

Copy link
Copy Markdown
Member Author

This is almost ready. The only thing left is a few copy tweaks on the Connect Drawer. Plan is to hold it and merge right after the next minor release ships.

@otavio

otavio commented Jul 2, 2026

Copy link
Copy Markdown
Member

/review

@shellhub-io shellhub-io deleted a comment from github-actions Bot Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 2m 10s —— View job


Code Review Complete

Reviewed 23 files across code quality, security, testing, language patterns, and architecture. 1 inline issue posted (1 medium).

Summary

  • RecordingSnackbar.tsx:43 — dead link (medium). The "View recordings" button navigates to /recordings, but this PR removed the standalone Recordings page and playback now lives in the Sessions list. There is no /recordings route in App.tsx, so the button lands on the catch-all instead of the recording it just announced. Suggested pointing it at /sessions.

Everything else checks out. Notably verified as sound:

  • Field plumbing — the new web flag is consistent across pkg/models, pkg/api/requests, the pg entity (both SessionFromModel/SessionToModel), the OpenAPI schema (added to required), and the SSH register() path.
  • Migration 008up/down are exact, idempotent inverses.
  • Cross-repo (cloud/)entity.Session embeds coreentity.Session, so the field is inherited with no cloud change.
  • SSH session-uid@shellhub.io handshake — returns ctx.SessionID() and only ever discloses the caller's own session id.
  • Recorder lifecycle — the OPFS recorder's StrictMode-remount vs. real-close handling (stillOpen, ref-nulling before await) is correct, and finalize is idempotent across ws.onclose + unmount cleanup.
  • Playback / permissions — asciicast data is parsed by asciinema-player, not injected as HTML; local playback intentionally skips session:play (user's own browser data) while server playback still routes through RestrictedAction.

To request another review round, comment /review.
· feature/web-terminal-session-recording

Comment thread ui/apps/console/src/components/terminal/RecordingSnackbar.tsx Outdated
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from 0daa47f to 216887f Compare July 6, 2026 18:42
Answer a session-uid@shellhub.io global request from the web bridge with
the connection's session uid (ctx.SessionID()) and relay it to the browser
over a new messageKindSession ws message, so a client-side recording can be
tied to its server session.
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from 216887f to 12d8dbc Compare July 6, 2026 19:19
Distinguish sessions started from the web terminal from native SSH
so the UI can label them and pick the right recording. Adds a
`web` field to the session model, request, service, and PG entity,
exposes it in the OpenAPI schema, and backs it with migration 008.
The SSH layer sets the flag when the session originates locally
(web terminal).
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from 12d8dbc to 0550975 Compare July 6, 2026 19:22
@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 12m 54s —— View job


Code Review Complete

Reviewed 23 files across code quality, security, testing, language patterns, and architecture. 2 inline issues posted (2 high).

Summary

  • ConnectDrawer.tsx:236 — local recording starts even when the namespace already records server-side (high). recordSession defaults to true and the opt-out checkbox is only shown when !namespaceRecords, but the connect gate never re-checks namespaceRecords. So connecting into a session_record-enabled namespace (OPFS-capable browser) silently also starts an unrequested local browser recording, contradicting the "recorded on the server" messaging. Suggested gating on !namespaceRecords.
  • TerminalInstance.tsx:276-291 — active recording is discarded (not finalized) on logout (high). stillOpen infers a real close from store membership, but TerminalManager lives in AppLayout, so logout unmounts the terminal while the session is still in useTerminalStore (logout() never clears it) → the discard() branch permanently deletes the in-progress OPFS recording. Suggested tracking an explicit "closing" signal instead.

Additional notes

One candidate finding was investigated and dropped as a false positive: setting RequestHandlers on the SSH server does not drop default global-request handlers — DefaultRequestHandlers is empty in the pinned shellhub-io/ssh fork (verified against the module cache), and unhandled requests already auto-reply, so there's no regression.

Everything else checks out and was verified as sound:

  • Field plumbing — the web flag is consistent across pkg/models, pkg/api/requests, the pg entity (both SessionFromModel/SessionToModel), the OpenAPI schema (added to required), and the SSH register() path.
  • Migration 008up/down are exact, idempotent inverses, correctly numbered against master.
  • Cross-repo (cloud/)entity.Session embeds coreentity.Session, so the field is inherited and scans/maps through with no cloud change.
  • SSH session-uid@shellhub.io — returns ctx.SessionID(), only ever disclosing the caller's own session id.
  • Playback / XSS — asciicast data is parsed by asciinema-player, not injected as HTML; local playback intentionally skips session:play (user's own browser data) while server playback still routes through RestrictedAction.
  • Tests — the events.types: ["shell"] and useNamespaces mock updates in Sessions.test.tsx/SecureVault.test.tsx are required by the new gating and stay deterministic under jsdom.

To request another review round, comment /review.
· feature/web-terminal-session-recording

Comment thread ui/apps/console/src/components/ConnectDrawer.tsx Outdated
Comment thread ui/apps/console/src/components/terminal/TerminalInstance.tsx
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from 0550975 to e56a979 Compare July 6, 2026 19:46
@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 10m 49s —— View job


Code Review Complete

Reviewed 24 files across code quality, security, testing, language patterns, and architecture. 1 inline issue posted (1 medium).

Summary

  • sessions/index.tsx:267 — local playback bypasses session:play across users on a shared browser (medium). OPFS recordings are scoped per browser origin, not per ShellHub user, and logout() never clears them. A second user who signs in on the same profile and shares a namespace can replay the previous user's local recording without holding session:play. Suggested clearing local recordings on logout, or scoping each recording to its creating user and only skipping the permission gate on an owner match.

Additional notes

Two candidate findings were investigated and dropped:

  • Unused recordingsStore helpers (remove/clearAll/setRetentionDays/download and their utils/recordings.ts backers) — the PR body explicitly defers local retention/cleanup to a dedicated settings page, so this is intentional scaffolding, not dead code.
  • SSH RequestHandlers replacing library defaults (ssh/server/server.go) — already investigated in the prior round and confirmed a non-issue: DefaultRequestHandlers is empty in the pinned shellhub-io/ssh fork and unhandled requests auto-reply.

Everything else checks out and was re-verified as sound:

  • Field plumbing — the web flag is consistent across pkg/models, pkg/api/requests (optional, backward-compatible), the pg entity (both SessionFromModel/SessionToModel), the OpenAPI schema (required), and the SSH register() path.
  • Migration 008up/down are exact, idempotent inverses; NOT NULL DEFAULT false backfills cleanly; no numbering collision on master.
  • Cross-repo (cloud/)entity.Session embeds coreentity.Session, so the field is inherited with no cloud change; no cloud write path needs the column.
  • SSH session-uid@shellhub.io — returns only ctx.SessionID(), disclosing just the caller's own session id.
  • Playback / XSS — asciicast data is parsed by asciinema-player (create({ data })), not injected as HTML; output events are JSON-escaped when written.
  • Prior fixes intact — the /recordings/sessions snackbar link, the !namespaceRecords recording gate, and the logout-finalizes-recording path all hold up.
  • Tests — the events.types: ["shell"] and useNamespaces mock updates in Sessions.test.tsx/SecureVault.test.tsx are required by the new gating and stay deterministic under jsdom.

To request another review round, comment /review.
· feature/web-terminal-session-recording

Comment thread ui/apps/console/src/pages/sessions/index.tsx
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from e56a979 to 91c3d06 Compare July 6, 2026 20:06
@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 9m 56s —— View job


Code Review Complete

Reviewed 24 files across code quality, security, testing, language patterns, and architecture. 1 inline issue posted (1 high).

Summary

  • utils/recordings.ts:207 — on logout, a finalized recording's sidecar is silently never written, orphaning the .cast (high). finish() re-derives the OPFS dir via recordingsDir(), which throws once userScope is null. logout() closes terminal sessions and then synchronously nulls userScope (via the authStore subscribe → setRecordingsScope(null)), so by the time the unmount cleanup runs finalizeRecording() the scope is already gone: the .cast closes fine but the .json sidecar write throws and is swallowed. Since listRecordings() enumerates only sidecars, the recording is invisible forever and the payload leaks OPFS — the same data loss the earlier logout-finalize fix targeted, moved to the metadata layer. Fix: capture the directory handle at create() and reuse it in finish()/discard()/removeFiles().

Additional notes

The prior four fixes were re-verified as sound and are not re-flagged:

  • Cross-user local playback — the per-user OPFS namespacing (session-recordings/<userId>/, synced from authStore) genuinely closes the shared-browser replay hole: userId is server-derived (never client-controllable), recordingsDir() throws without a scope, and the session:play gate is skipped only for the current user's own listRecordings() results. Server recordings still route through RestrictedAction.
  • session-uid@shellhub.io returns only ctx.SessionID() (caller's own id); asciicast is parsed by asciinema-player, not injected as HTML — no XSS.
  • Field plumbing / migration 008 / cross-repo — the web flag is consistent across pkg/models, pkg/api/requests, the pg entity, the OpenAPI required set, and the SSH register() path; 008 up/down are exact inverses with no numbering collision (cloud migrations run through a separate bun_cloud_migrations table); cloud/entity.Session inherits the field with no cloud change.
  • Tests — the events.types: ["shell"] and useNamespace mock updates are required by the new gating and stay deterministic under jsdom (isRecordingSupported() is false there).

To request another review round, comment /review.
· feature/web-terminal-session-recording

Comment thread ui/apps/console/src/utils/recordings.ts Outdated
Capture web terminal sessions client-side to OPFS and replay them
from the Sessions page. Recording is opt-in from the Connect drawer
(on by default); when the namespace already records server-side, the
drawer shows a live indicator instead. Playback prefers a local
recording and falls back to the server copy; Community without a
recording gets an upsell.
@gustavosbarreto gustavosbarreto force-pushed the feature/web-terminal-session-recording branch from 91c3d06 to e6a79bb Compare July 6, 2026 20:22
@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 9m 34s —— View job


Code Review Complete

Reviewed 24 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

All findings from the prior review rounds were re-verified as soundly addressed:

  • /recordings/sessions snackbar link, the !namespaceRecords recording gate, and the logout-finalizes-recording path all hold up.
  • Per-user OPFS scoping (session-recordings/<userId>/) genuinely closes the shared-browser replay hole — userId is server-derived (never client-controllable), recordingsDir() throws without a scope, and the session:play gate is skipped only for the current user's own local recordings. Server playback still routes through RestrictedAction.
  • Directory-handle captureOpfsCastRecorder captures this.dir at create() and reuses it in finish()/removeFiles(), so finalize-on-logout writes the sidecar into the creating user's dir even after userScope is nulled. No orphaned .cast.

Also re-verified as sound:

  • Field plumbing — the web flag is consistent across pkg/models, pkg/api/requests, the pg entity (both SessionFromModel/SessionToModel), the OpenAPI schema (properties + required), and the SSH register() path.
  • Migration 008up/down are exact, idempotent inverses; NOT NULL DEFAULT false backfills cleanly; no numbering collision (cloud migrations use a separate bun_cloud_migrations table).
  • Cross-repo (cloud/)entity.Session embeds coreentity.Session, so the field is inherited and maps through SessionToModel with no cloud change.
  • SSH session-uid@shellhub.io — returns only ctx.SessionID(), disclosing just the caller's own session id; setting RequestHandlers is strictly more restrictive (reverse forwarding already disabled via ReversePortForwardingCallback).
  • Playback / XSS — asciicast data is parsed by asciinema-player (create({ data })), not injected as HTML.
  • Tests — the events.types: ["shell"] and useNamespace mock updates in Sessions.test.tsx/SecureVault.test.tsx are required by the new gating and stay deterministic under jsdom (isRecordingSupported() is false there).

To request another review round, comment /review.
· feature/web-terminal-session-recording

@gustavosbarreto gustavosbarreto enabled auto-merge (rebase) July 6, 2026 21:06
@gustavosbarreto gustavosbarreto merged commit 9cfbe13 into master Jul 6, 2026
50 checks passed
@gustavosbarreto gustavosbarreto deleted the feature/web-terminal-session-recording branch July 6, 2026 21:16
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