release-2.6.0 (#1085)

* fix: general bug fixes

* fix: general qol additions

* ci(deps): bump actions/setup-node in the github-actions group (#1068)

Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 28 updates (#1069)

Bumps the dev-patch-updates group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` |
| [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` |


Updates `@biomejs/biome` from 2.5.2 to 2.5.4
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.4/packages/@biomejs/biome)

Updates `@codemirror/view` from 6.43.5 to 6.43.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@radix-ui/react-accordion` from 1.2.15 to 1.2.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion)

Updates `@radix-ui/react-alert-dialog` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.6 to 1.3.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-dialog` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.19 to 2.1.21
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.11 to 2.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.11 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-scroll-area` from 1.2.13 to 1.2.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.2 to 2.3.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.4.2 to 1.4.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-switch` from 1.3.2 to 1.3.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.16 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.11 to 1.2.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite)

Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/ui)

Updates `i18next` from 26.3.4 to 26.3.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.4...v26.3.6)

Updates `radix-ui` from 1.6.1 to 1.6.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-i18next` from 17.0.8 to 17.0.10
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10)

Updates `tailwindcss` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-theme-github"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump ws in the prod-patch-updates group (#1071)

Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.21.0 to 8.21.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 2 updates (#1072)

Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript).


Updates `nanoid` from 5.1.16 to 6.0.0
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.16...6.0.0)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: general qol additions and new analytics/telemetrics feature

* fix: incorrect version sent to posthog

* feat: add multiplayer/shared sessions for terminal and guacd

* feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server

* Fix Guacamole tab visibility lifecycle (#1074)

Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix(alerts): send channel config as object payload instead of JSON string (#1075)

* fix tmux-monitor tailscale issue (#1076)

* Initial plan

* fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig

For Tailscale-auth hosts the pane-preview attach path was building the
Terminal hostConfig with only the generic spread of host fields.  This
could omit or mismap auth-critical details and trigger a plain TCP/SSH
reachability path that doesn't work with Tailscale-only SSH endpoints.

The fix branches on `host.authType === "tailscale"` and:
- Carries `authType: "tailscale"` explicitly so the backend always selects
  the Tailscale-aware PTY path regardless of how the host object evolves.
- Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH
  endpoints on a non-default SSH port are reached correctly.
- Leaves all non-tailscale auth types on the unchanged code path.

Reattach (bumping instanceId + attachNonce) continues to work because
terminalHostConfig is recomputed on every render with the latest
instanceIdRef.current value.

* refactor(tmux-monitor): simplify tailscale port logic with extracted variable

Address code review feedback: extract resolvedPort into a local variable
to avoid the duplicated `host.sshPort ?? host.port` expression that was
assigned to both `port` and `sshPort`.  Restructure as an if/else block
instead of an IIFE for readability.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: run prettier

* chore: update beta release text

* fix: cant update credential of an RDP host

* feat: add custom key shortcuts

* feat: add support for MFA over SSH

* fix: Invalid websocket frame causing code 10006 crash triggering restart loop

* fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079)

* fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4

::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once
mapped, since Node's BlockList compares addresses in their mapped form
internally regardless of the declared family. This caused
safeOutboundFetch to reject all IPv4-resolved destinations as private,
breaking outbound requests (e.g. ntfy/webhook notifications) whenever
DNS resolved to IPv4. Replaced with individual mapped ranges mirroring
the existing IPv4 blocklist.

* test: cover isBlockedAddress and link the Node BlockList citation

Exports isBlockedAddress so its family-crossing behavior around
IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying
on manual container debugging to notice a regression. Also swaps the
prior "Node's BlockList compares addresses in mapped form" comment for
one citing the documented example in the Node docs
(https://nodejs.org/api/net.html#class-netblocklist), since that
behavior isn't otherwise obvious from the addSubnet/check call sites.

Related: Termix-SSH/Support#1024

* refactor: derive IPv6 mirror from IPv4 list, split DNS error messages

Two follow-ups from review:

- The IPv6 blocklist previously hand-duplicated each IPv4 range as its
  IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync,
  which is exactly how the original bug (a mismatched ::ffff:0:0/96
  entry blocking all IPv4) was introduced in the first place. Now
  derived from a single blockedIpv4Ranges list in one loop.

- The connect.lookup hook threw the same "Private destinations are not
  allowed" for both an empty DNS result and an actually-blocked address.
  An empty result is a resolution failure, not a privacy decision, and
  conflating the two is the same kind of opaque-error problem that made
  this bug slow to diagnose in production. Split into distinct messages.

Also extracted the lookup hook itself (createDnsLookupHook) so it can be
unit-tested against a fake resolver directly, instead of only through a
real fetch()/Agent call — the bug lived entirely in this callback, and
undici wraps any error thrown here as a generic "fetch failed" TypeError,
which is why isolating it matters for testability.

---------

Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>

* fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081)

The availability probe treated a working OpenSSL "chacha20" cipher as proof
that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS
chacha20-poly1305 corrupts the transport, so the peer aborts the KEX
("incomplete message [preauth]") and the connection times out. Easy to hit
on jump-host connections whose target sshd negotiates chacha20-poly1305
first.

Only trust the native binding (sshcrypto.node); otherwise leave the cipher
disabled so filterCiphers() drops it and AES-GCM is negotiated instead.

Co-authored-by: XtraLarge <>

* fix: add Swiss German server layout (#1078)

* chore: update release notes

* feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog

* fix: dekstop app showing auth form without syncing

* feat: create desktop auto sessions for existing setups

* feat: add electron backend killing

* fix: electron login and session related bugs and updated readme for v2.6.0

* chore: finalize release notes

* fix: click to expand hosts including extra bottom margin

* fix: desktop auth modal failing to log users in

* fix: desktop app failing to sync

* fix: reverse proxy causing sync error

* chore: lint, format, and bump version to 2.6.0

* chore: sync Crowdin translations for 2.6.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com>
Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com>
Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
232 files changed
+50585 -32016

No files matched your search

@@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { SSHAuthManager } from "../../hosts/auth-manager.js";
function createManager() {
const sent: Record<string, unknown>[] = [];
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
const manager = new SSHAuthManager({
userId: "user-1",
ws,
hostId: 1,
isKeyboardInteractive: false,
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
warpgateAuthPromptSent: false,
totpTimeout: null,
warpgateAuthTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
}
describe("SSHAuthManager.handleKeyboardInteractive", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("routes a TOTP verification prompt to the totp flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Verification code: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{ type: "totp_required", prompt: "Verification code: " },
]);
expect(finish).not.toHaveBeenCalled();
});
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Choose [1] Push, or [2] TOTP: ",
echo: true,
},
]);
});
it("silently auto-answers a plain password prompt when a stored password exists", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Password: ", echo: false }],
finish,
{ username: "root", password: "hunter2", authType: "password" },
);
expect(finish).toHaveBeenCalledWith(["hunter2"]);
expect(sent).toEqual([]);
});
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Press enter to send Push request: ",
echo: true,
},
]);
manager.context.keyboardInteractiveFinish?.([""]);
expect(finish).toHaveBeenCalledWith([""]);
});
it("uses a longer timeout for push-style prompts than generic prompts", () => {
vi.useFakeTimers();
try {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
vi.advanceTimersByTime(180001);
expect(sent.some((m) => m.type === "error")).toBe(false);
vi.advanceTimersByTime(120000);
expect(sent.some((m) => m.type === "error")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Warpgate Authentication",
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Warpgate authentication required",
},
},
{
type: "warpgate_auth_required",
url: "https://warpgate.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
},
]);
});
});
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
ownerUserId: "user-1",
protocol: "rdp" as const,
};
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
{},
undefined,
termixMeta,
);
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
});
it("createJoinToken sets connection.join, not connection.type", () => {
const token = tokenService.createJoinToken("guacd-conn-123", true);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-123");
expect(decrypted?.connection.type).toBeUndefined();
expect(decrypted?.connection.readOnly).toBe(true);
});
it("createJoinToken round-trips a read-write join through decryptToken", () => {
const token = tokenService.createJoinToken("guacd-conn-456", false);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-456");
expect(decrypted?.connection.readOnly).toBe(false);
expect(decrypted?.recording).toBeUndefined();
});
});
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
credentials: new Map<string, Record<string, unknown>>(),
sharedSecret: null as Record<string, unknown> | null,
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
findOverrideCredentialId: async () => state.overrideCredentialId,
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ?? null,
findFolderCredentialId: async () => state.folderCredentialId,
}),
createCurrentVaultProfileRepository: () => ({
findById: async () => null,
@@ -101,6 +103,7 @@ beforeEach(() => {
state.credentials.clear();
state.sharedSecret = null;
state.auditCalls = [];
state.folderCredentialId = null;
});
describe("resolveHostById", () => {
@@ -138,6 +141,63 @@ describe("resolveHostById", () => {
expect(host.sudoPassword).toBe("owner-sudo");
});
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
state.host = baseHost({
authType: "credential",
credentialId: null,
folder: "switches",
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("11:owner", {
id: 11,
username: "folder-user",
authType: "password",
password: "folder-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.password).toBe("folder-pass");
expect(host.username).toBe("folder-user");
expect(host.authType).toBe("password");
});
it("prefers the host's own credential over its folder's credential", async () => {
state.host = baseHost({
authType: "credential",
credentialId: 9,
folder: "switches",
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("9:owner", {
id: 9,
username: "host-user",
authType: "password",
password: "host-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.username).toBe("host-user");
expect(host.password).toBe("host-pass");
});
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
state.host = baseHost({ username: "" });
state.sharedSecret = {
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import {
parseDfLines,
findWorstMountIndex,
} from "../../../../hosts/metrics/widgets/disk-collector.js";
describe("parseDfLines", () => {
it("parses df -P output into rows", () => {
const output =
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay 100 50 50 50% /\n" +
"/dev/sda1 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
@@ -0,0 +1,513 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
const state = vi.hoisted(() => ({
currentUserId: "user-1",
globalSharingEnabled: true,
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
guacSessions: new Map<
string,
{ ownerUserId: string; hostId: number; protocol: string }
>(),
shares: new Map<string, Record<string, unknown>>(),
admins: new Set<string>(),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (
userId: string,
hostId: number,
_action: string,
) => ({
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
}),
isAdmin: async (userId: string) => state.admins.has(userId),
}),
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
getSession: (sessionId: string) => {
const session = state.sshSessions.get(sessionId);
if (!session) return null;
return { ...session };
},
ownerEndSession: vi.fn(),
},
}));
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
getGuacSessionInfo: (guacamoleConnectionId: string) =>
state.guacSessions.get(guacamoleConnectionId) ?? null,
}));
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
GuacamoleTokenService: {
getInstance: () => ({
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
`join-token:${guacamoleConnectionId}:${readOnly}`,
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSessionShareRepository: () => ({
create: async (input: Record<string, unknown>) => {
const row = {
...input,
createdAt: "2026-07-20T00:00:00.000Z",
revokedAt: null,
lastJoinedAt: null,
joinCount: 0,
};
state.shares.set(input.id as string, row);
return row;
},
findById: async (id: string) => state.shares.get(id) ?? null,
findByLinkToken: async (linkToken: string) => {
for (const share of state.shares.values()) {
if (
share.linkToken === linkToken &&
!share.revokedAt &&
(share.expiresAt as string) > new Date().toISOString()
) {
return share;
}
}
return null;
},
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
return [...state.shares.values()].filter(
(s) =>
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
);
},
revoke: async (shareId: string, requestingUserId: string) => {
const share = state.shares.get(shareId);
if (!share || share.ownerUserId !== requestingUserId) return false;
share.revokedAt = "2026-07-20T01:00:00.000Z";
return true;
},
revokeAsAdmin: async (shareId: string) => {
const share = state.shares.get(shareId);
if (!share) return false;
share.revokedAt = "2026-07-20T01:00:00.000Z";
return true;
},
touchShareUsage: async () => {},
recordParticipantJoin: async () => ({ id: 1 }),
}),
createCurrentSettingsRepository: () => ({
getBoolean: async () => state.globalSharingEnabled,
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async (hostId: number) =>
state.hosts.get(hostId)?.userId ?? null,
findHostById: async (hostId: number) => {
const host = state.hosts.get(hostId);
if (!host) return null;
return { allowSessionSharing: host.allowSessionSharing };
},
}),
}));
const { default: router } =
await import("../../../hosts/session-sharing/routes.js");
type RouteLayer = {
route?: {
path: string;
methods: Record<string, boolean>;
stack: {
handle: (req: Request, res: Response, next: () => void) => unknown;
}[];
};
};
function findHandlers(method: string, path: string) {
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
const layer = layers.find(
(l) => l.route?.path === path && l.route.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack.map((s) => s.handle);
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
ip?: string;
}) {
const req = {
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
ip: overrides.ip ?? "127.0.0.1",
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
ip?: string;
} = {},
) {
const handlers = findHandlers(method, path);
const { req, res } = makeReqRes(overrides);
for (const handler of handlers) {
let calledNext = false;
await handler(req, res, () => {
calledNext = true;
});
if (!calledNext) break;
}
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.globalSharingEnabled = true;
state.hosts = new Map([
[1, { userId: "user-1", allowSessionSharing: true }],
[2, { userId: "user-1", allowSessionSharing: false }],
]);
state.hostOwnerAccess = new Map([["user-2:1", true]]);
state.sshSessions = new Map([
["session-1", { userId: "user-1", isConnected: true }],
]);
state.guacSessions = new Map([
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
]);
state.shares = new Map();
state.admins = new Set();
});
describe("POST /session-sharing/create", () => {
it("rejects a caller who does not own the live session", async () => {
state.currentUserId = "user-2";
const res = await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
expect(res.statusCode).toBe(403);
expect(res.jsonBody).toMatchObject({
error: "You do not own this live session",
});
});
it("creates a link share for the session owner", async () => {
const res = await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
});
it("rejects a user share when the target lacks host access", async () => {
const res = await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "user",
targetUserId: "no-access-user",
permissionLevel: "read-write",
},
});
expect(res.statusCode).toBe(403);
expect(res.jsonBody).toMatchObject({
error: "Target user does not have access to this host",
});
});
it("global kill switch overrides an enabled per-host toggle", async () => {
state.globalSharingEnabled = false;
const res = await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
expect(res.statusCode).toBe(403);
expect(res.jsonBody).toMatchObject({
error: "Session sharing is disabled for this host",
});
});
it("rejects when the per-host toggle is off even though global is on", async () => {
const res = await invoke("post", "/create", {
body: {
hostId: 2,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
expect(res.statusCode).toBe(403);
});
});
describe("GET /session-sharing/resolve/:linkToken", () => {
async function createActiveLinkShare(
overrides: Partial<Record<string, unknown>> = {},
) {
await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
...overrides,
},
});
const [share] = [...state.shares.values()];
return share as { linkToken: string; id: string };
}
it("never includes hostname, ip, username, or hostId in the response body", async () => {
const share = await createActiveLinkShare();
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: share.linkToken },
});
expect(res.statusCode).toBe(200);
const body = res.jsonBody as Record<string, unknown>;
const serialized = JSON.stringify(body).toLowerCase();
expect(body).not.toHaveProperty("hostname");
expect(body).not.toHaveProperty("ip");
expect(body).not.toHaveProperty("username");
expect(body).not.toHaveProperty("hostId");
expect(body).not.toHaveProperty("hostName");
expect(serialized).not.toContain("10.0.0");
expect(serialized).not.toContain("hostname");
expect(serialized).not.toContain('"ip"');
expect(serialized).not.toContain("username");
});
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
const share = await createActiveLinkShare();
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: share.linkToken },
});
expect(res.jsonBody).toEqual({
protocol: "ssh",
permissionLevel: "read-only",
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
});
});
it("mints a fresh join token for guac protocols", async () => {
await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "guac-conn-1",
protocol: "vnc",
shareType: "link",
permissionLevel: "read-only",
},
});
const [share] = [...state.shares.values()] as {
linkToken: string;
}[];
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: share.linkToken },
});
expect(res.statusCode).toBe(200);
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
token: "join-token:guac-conn-1:true",
});
});
it("rejects an unknown link token", async () => {
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: "does-not-exist" },
});
expect(res.statusCode).toBe(404);
});
it("rejects a revoked link token", async () => {
const share = await createActiveLinkShare();
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: share.linkToken },
});
expect(res.statusCode).toBe(404);
});
it("rejects an expired link token", async () => {
state.shares.set("share-expired", {
id: "share-expired",
hostId: 1,
ownerUserId: "user-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "expired-token",
permissionLevel: "read-only",
expiresAt: "2000-01-01T00:00:00.000Z",
revokedAt: null,
});
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: "expired-token" },
});
expect(res.statusCode).toBe(404);
});
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
const share = await createActiveLinkShare();
state.globalSharingEnabled = false;
const res = await invoke("get", "/resolve/:linkToken", {
params: { linkToken: share.linkToken },
});
expect(res.statusCode).toBe(404);
});
});
describe("DELETE /session-sharing/:shareId", () => {
it("allows the owner to revoke their own share", async () => {
await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
const [share] = [...state.shares.values()] as { id: string }[];
const res = await invoke("delete", "/:shareId", {
params: { shareId: share.id },
});
expect(res.statusCode).toBe(200);
});
it("rejects a non-owner, non-admin caller", async () => {
await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
const [share] = [...state.shares.values()] as { id: string }[];
state.currentUserId = "user-2";
const res = await invoke("delete", "/:shareId", {
params: { shareId: share.id },
});
expect(res.statusCode).toBe(403);
});
it("allows an admin to revoke someone else's share", async () => {
await invoke("post", "/create", {
body: {
hostId: 1,
sessionId: "session-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
},
});
const [share] = [...state.shares.values()] as { id: string }[];
state.currentUserId = "admin-1";
state.admins.add("admin-1");
const res = await invoke("delete", "/:shareId", {
params: { shareId: share.id },
});
expect(res.statusCode).toBe(200);
});
});
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
},
}));
const { sessionManager } =
const { sessionManager, isMessageAllowedForParticipant } =
await import("../../../hosts/terminal/session-manager.js");
// Minimal fake WebSocket - only the surface session-manager touches.
function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
const WS_CLOSED = 3;
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
sessionManager.destroySession(id);
});
});
describe("TerminalSessionManager - multiplayer participants", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockCreate.mockResolvedValue({ id: 1 });
mockUpdateEnded.mockResolvedValue(undefined);
});
function createConnectedSession(): string {
const id = sessionManager.createSession(
"owner-1",
1,
"host",
80,
24,
undefined,
false,
);
// Mark connected without a real ssh2 stream - only isConnected is read
// by attachWs/joinAsParticipant.
const session = sessionManager.getSession(id)!;
session.isConnected = true;
return id;
}
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest",
});
expect(session).not.toBeNull();
expect(session!.participants.size).toBe(2);
const ownerParticipant = sessionManager.getParticipantForWs(
session!,
ownerWs,
);
expect(ownerParticipant?.isOwner).toBe(true);
expect(ownerWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
userId: null,
permissionLevel: "read-only",
}),
).toBeNull();
});
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs(WS_OPEN);
sessionManager.attachWs(id, "owner-1", ownerWs);
const openGuestWs = makeFakeWs(WS_OPEN);
const closedGuestWs = makeFakeWs(WS_CLOSED);
sessionManager.joinAsParticipant(id, openGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.joinAsParticipant(id, closedGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.broadcast(id, { type: "data", data: "hello" });
expect(ownerWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(openGuestWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(closedGuestWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("broadcast does not throw if a socket's send throws", () => {
const id = createConnectedSession();
const throwingWs = makeFakeWs(WS_OPEN);
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("send failed");
});
sessionManager.attachWs(id, "owner-1", throwingWs);
expect(() =>
sessionManager.broadcast(id, { type: "data", data: "x" }),
).not.toThrow();
sessionManager.destroySession(id);
});
it("broadcast is a no-op for a nonexistent session", () => {
expect(() =>
sessionManager.broadcast("does-not-exist", { type: "data" }),
).not.toThrow();
});
it("owner detach arms the idle timeout (existing behavior)", () => {
vi.useFakeTimers();
try {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.detachWs(id);
const session = sessionManager.getSession(id);
expect(session?.detachTimeout).not.toBeNull();
expect(session?.lastDetachedAt).not.toBeNull();
sessionManager.destroySession(id);
} finally {
vi.useRealTimers();
}
});
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.removeParticipant(id, guestWs);
const session = sessionManager.getSession(id);
expect(session).not.toBeNull();
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
sessionManager.destroySession(id);
});
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.removeParticipant(id, ownerWs);
const session = sessionManager.getSession(id);
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
true,
);
sessionManager.destroySession(id);
});
it("destroySession cleans up all participants, not just the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.destroySession(id);
expect(guestWs.send).toHaveBeenCalled();
expect(sessionManager.getSession(id)).toBeNull();
});
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.ownerEndSession(id, "owner ended the session");
expect(guestWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: "sessionTerminatedByOwner",
reason: "owner ended the session",
}),
);
expect(sessionManager.getSession(id)).toBeNull();
});
});
describe("isMessageAllowedForParticipant", () => {
it("allows any message type for the owner or when there is no participant", () => {
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: true, permissionLevel: "read-write" },
"resize",
),
).toBe(true);
});
it("drops input from a read-only participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"input",
),
).toBe(false);
});
it("allows input from a read-write non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
"input",
),
).toBe(true);
});
it("allows ping and disconnect for any non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"ping",
),
).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"disconnect",
),
).toBe(true);
});
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
for (const type of [
"resize",
"totp_response",
"password_response",
"tmux_attach",
"tmux_detach",
"get_cwd",
"vault_start_auth",
"opkssh_start_auth",
]) {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
type,
),
).toBe(false);
}
});
});