diff --git a/.github/workflows/ssh-desktop.yml b/.github/workflows/ssh-desktop.yml
new file mode 100644
index 00000000..078e7024
--- /dev/null
+++ b/.github/workflows/ssh-desktop.yml
@@ -0,0 +1,122 @@
+name: SSH desktop foundation
+
+on:
+ pull_request:
+ paths:
+ - '.github/workflows/ssh-desktop.yml'
+ - 'cmd/atenea-ssh-controller/**'
+ - 'internal/sshcontrol/**'
+ - 'desktop/ssh/**'
+ - 'go.mod'
+ - 'go.sum'
+
+jobs:
+ native-build:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-15, windows-2025, ubuntu-24.04]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.26.7'
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: '1.4.2'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Linux WebView build dependencies
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb xauth xdotool imagemagick dbus-x11 weston
+ - name: Frontend check and build
+ working-directory: desktop/ssh/frontend
+ shell: bash
+ run: |
+ bun install --frozen-lockfile
+ bun run check
+ bun run build
+ - name: Native controller tests
+ shell: bash
+ run: go test -race -count=1 ./internal/sshcontrol/...
+ - name: Build Wails shell
+ working-directory: desktop/ssh
+ shell: bash
+ run: |
+ python scripts/restricted_wails.py test
+ python scripts/restricted_wails.py build
+ - name: Check Linux launcher diagnostics
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: bash scripts/linux_launcher_test.sh
+ - name: Bundle controller beside window
+ shell: bash
+ run: |
+ case "${{ runner.os }}" in
+ macOS) target=desktop/ssh/build/bin/atenea-ssh.app/Contents/MacOS/atenea-ssh-controller ;;
+ Windows) target=desktop/ssh/build/bin/atenea-ssh-controller.exe ;;
+ Linux) target=desktop/ssh/build/bin/atenea-ssh-controller ;;
+ esac
+ go build -o "$target" ./cmd/atenea-ssh-controller
+ - name: Render Linux window in a virtual X11 session
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh
+ - name: Render copied Linux package through a Terminal link
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: |
+ install_root=$(mktemp -d)
+ trap 'rm -rf "$install_root"' EXIT
+ mkdir -p "$install_root/bin" "$install_root/command" "$install_root/config"
+ install -m 755 build/bin/atenea-ssh build/bin/atenea-ssh-bin build/bin/atenea-ssh-controller "$install_root/bin/"
+ ln -s ../bin/atenea-ssh "$install_root/command/atenea-ssh"
+ ATENEA_SSH_SHELL="$install_root/command/atenea-ssh" \
+ ATENEA_SSH_CONTROLLER="$install_root/bin/atenea-ssh-controller" \
+ ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-installed-link.png \
+ XDG_CONFIG_HOME="$install_root/config" \
+ dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh
+ - name: Exercise Linux lifecycle in a virtual Wayland session
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: dbus-run-session -- bash scripts/linux_wayland_lifecycle.sh
+ - name: Probe Linux WebView bridge in virtual X11
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: |
+ python scripts/restricted_wails.py probe-build
+ (cd ../.. && go build -o desktop/ssh/build/bin/atenea-ssh-controller ./cmd/atenea-ssh-controller)
+ ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-bridge-probe.png dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh
+ - name: Capture Linux WebView navigation probe in virtual X11
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: |
+ python scripts/restricted_wails.py navigation-probe-build
+ (cd ../.. && go build -o desktop/ssh/build/bin/atenea-ssh-controller ./cmd/atenea-ssh-controller)
+ ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-navigation-probe.png dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh
+ - name: Capture Linux WebView navigation probe in virtual Wayland
+ if: runner.os == 'Linux'
+ working-directory: desktop/ssh
+ shell: bash
+ run: ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-wayland-navigation-probe.png ATENEA_SSH_CAPTURE_DELAY=7 dbus-run-session -- bash scripts/linux_wayland_lifecycle.sh
+ - name: Save Linux window capture
+ if: runner.os == 'Linux' && always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: atenea-ssh-linux-window
+ path: |
+ desktop/ssh/build/ci-artifacts/linux-render-smoke.png
+ desktop/ssh/build/ci-artifacts/linux-installed-link.png
+ desktop/ssh/build/ci-artifacts/linux-bridge-probe.png
+ desktop/ssh/build/ci-artifacts/linux-navigation-probe.png
+ desktop/ssh/build/ci-artifacts/linux-wayland.png
+ desktop/ssh/build/ci-artifacts/linux-wayland-navigation-probe.png
+ if-no-files-found: ignore
diff --git a/cmd/atenea-ssh-controller/main.go b/cmd/atenea-ssh-controller/main.go
new file mode 100644
index 00000000..9bf9c7b8
--- /dev/null
+++ b/cmd/atenea-ssh-controller/main.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "time"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/controller"
+)
+
+// The controller deliberately exposes no SSH commands in this foundation.
+func main() {
+ rootFlag := flag.String("root", "", "absolute per-user state directory")
+ stopFlag := flag.Bool("stop", false, "stop the current user's controller")
+ flag.Parse()
+ if flag.NArg() != 0 {
+ fmt.Fprintln(os.Stderr, "unexpected arguments")
+ os.Exit(2)
+ }
+ root := *rootFlag
+ if root == "" {
+ var err error
+ root, err = controller.Root()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ }
+ id, err := controller.InstallationID(root)
+ if err == nil && *stopFlag {
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+ _, err = controller.Call(ctx, root, id, "stop")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
+ }
+ if err == nil {
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer cancel()
+ err = controller.Serve(ctx, root, id)
+ }
+ if err != nil && !errors.Is(err, context.Canceled) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/desktop/ssh/.gitignore b/desktop/ssh/.gitignore
new file mode 100644
index 00000000..28289704
--- /dev/null
+++ b/desktop/ssh/.gitignore
@@ -0,0 +1,8 @@
+/build/bin/
+/build/ci-artifacts/
+/frontend/node_modules/
+/frontend/dist/*
+!/frontend/dist/gitkeep
+/frontend/wailsjs/
+/frontend/package.json.md5
+/ssh
diff --git a/desktop/ssh/README.md b/desktop/ssh/README.md
new file mode 100644
index 00000000..630a8aba
--- /dev/null
+++ b/desktop/ssh/README.md
@@ -0,0 +1,216 @@
+# Atenea SSH desktop foundation
+
+This is a fixture-only Wails v2.15.0 shell and a separate user-owned Go
+controller. It does not read SSH config, store credentials, open remote
+connections or run prompts. The example addresses use the reserved
+documentation network `192.0.2.0/24`.
+
+## Local build
+
+Use Go 1.26.7, Python 3.12 and Bun 1.4.2. From this directory:
+
+```sh
+GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py test
+GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py build
+# To build the guarded Windows amd64 shell from a macOS host:
+ATENEA_WAILS_PLATFORM=windows/amd64 GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py build
+# On macOS, from the repository root:
+GOTOOLCHAIN=go1.26.7 go build -o desktop/ssh/build/bin/atenea-ssh.app/Contents/MacOS/atenea-ssh-controller ./cmd/atenea-ssh-controller
+```
+
+On macOS the controller binary belongs beside the window executable inside
+`build/bin/atenea-ssh.app/Contents/MacOS/`; on Windows it belongs beside the
+window executable. On Linux, the build puts `atenea-ssh` (launcher),
+`atenea-ssh-bin` (native window) and `atenea-ssh-controller` in the same
+directory. Install all three together. The launcher checks for a graphical
+session and linked libraries before starting the window, with actionable
+errors on stderr. A Terminal link to the launcher resolves to that package
+directory, with a bounded link chain; the executable and controller still stay
+together there. For Ubuntu 24.04, the required runtime packages include
+`libgtk-3-0` and `libwebkit2gtk-4.1-0`; see the [Wails Linux runtime guide](https://wails.io/docs/guides/linux-distro-support/).
+Ubuntu CI also copies the three executables to a separate temporary package
+directory and opens the window through a relative Terminal link under virtual
+X11. Its capture proves that this package layout launches in the CI session;
+it does not represent an installer or a clean desktop installation.
+These checks do not prove that a stale display address can open a window or
+that a clean machine has every runtime requirement. The window starts the
+controller on demand. Closing the window does
+not stop it. A later lifecycle UI will provide explicit stop/restart.
+For a manual stop, run the installed controller executable with `--stop`.
+On macOS and Linux, the window checks a user-state path that exceeds the Unix
+socket address limit before launching the controller and reports the path problem.
+This does not relocate existing state or make an overlong path usable.
+A macOS 26.6.2 arm64 fixture trial used a 107-byte socket path: the window
+displayed the path error and created no user-state files. A separate shorter
+temporary path launched the sibling controller and rendered its available state.
+
+`bun run check` and `bun run build` run in `frontend/`. The browser development
+preview only renders synthetic fixtures; it reports that the controller bridge
+is unavailable.
+
+## Current security boundary
+
+The application Bind list exposes only `ControllerStatus`, which takes no
+JavaScript arguments. The controller accepts only `status` and `stop` operations
+after native peer authentication and protocol negotiation. The pinned Wails
+source is copied into a temporary Go workspace during build. Exact upstream
+source hashes are checked before the script restricts the dispatcher and the
+three platform message entry points. The app references a marker available only
+in that patched copy, so `go build ./...` without the script fails to compile.
+Call messages over 4096 bytes are rejected at each native entry point before
+they reach the dispatcher; the Windows additional-objects path uses the same
+limit.
+The only permitted JavaScript binding is the zero-argument status call; native
+window close (`Q`) and framework readiness signals remain available. Browser,
+clipboard, notification, window-control, drag/resize/file-drop and obfuscated
+binding messages are rejected before their framework handlers. The frontend
+also sets a packaged-assets-only CSP. The guarded Windows Wails copy changes
+WebView2's global permission setting from allow to deny; the fixture UI does
+not request native browser permissions.
+
+The guarded macOS WKWebView cancels navigation and new windows outside the
+packaged `wails://wails/` page. The guarded Linux WebKitGTK window applies
+the same restriction through its native navigation policy. Windows WebView2
+uses native navigation-starting and new-window events to keep its document
+at `http://wails.localhost/`. The build script checks exact source hashes and
+compiles these hooks into temporary copies of Wails and go-webview2. Broader
+navigation trials and native Linux desktop sessions remain necessary before
+this boundary is accepted.
+
+The per-user installation ID is written completely to a private temporary
+file before being published atomically. Concurrent first launches therefore
+read the same completed ID. Native endpoint ownership still allows only one
+controller listener. The concurrent-ID tests cover simultaneous callers in one
+process and eight independent processes. A separate process test starts a
+controller, checks that a different installation cannot stop it and that an
+incompatible protocol is closed before any operation, then verifies the
+legitimate client still works. It also checks that a second controller cannot
+take the endpoint, kills the owner, restarts it and verifies status and explicit
+stop. This exercises stale endpoint recovery with synthetic state. Real macOS
+and Windows graphical-session trials of simultaneous GUI activation are
+recorded below; Linux, another-user sessions, logout and sleep/resume remain
+separate acceptance checks.
+
+The guard has direct dispatcher tests, a shared ingress-policy test and
+rendered macOS and Windows launch checks. Diagnostic screen and clipboard
+calls did not return data in the tested WebViews. The current Windows and
+virtual X11 probes distinguish a rejection from a timeout: both calls timed
+out after 1.5 seconds, while the permitted controller status call succeeded.
+A timeout alone does not prove explicit rejection or exclude a later response.
+Other hostile calls, real Linux desktop and Wayland WebView behavior, broader
+navigation probes, and clean installation
+remain open in issue #151. Do not ship this shell with real SSH data or
+credentials until those gates pass. Development preview uses only synthetic
+fixtures.
+
+The optional `probe-build` command creates a local synthetic diagnostic app
+that calls framework screen and clipboard read methods directly from its
+WebView. It reports resolved, rejected, timed out or unavailable for each
+call, never a returned value. It also sends a direct window-title command;
+the virtual X11 smoke test checks that the native title remains `Atenea SSH`.
+The normal `build` command removes the probe flag and verifies that its label
+and direct title command are absent from the bundled JavaScript.
+The fixture search shows a focus ring on keyboard focus, and the history
+shortcut uses an immediate scroll when reduced motion is requested. Native
+keyboard and assistive-technology acceptance remains open.
+The separate `navigation-probe-build` attempts a top-level navigation to a
+loopback URL with no SSH data. A surviving fixture window after the attempt
+is an observation of blocked navigation; it does not alone identify which
+browser or native policy stopped it. Normal builds verify that the probe URL
+is absent from the bundled JavaScript.
+
+## Platform evidence
+
+| Platform | Build | Render/launch | Installed package | Runtime dependency |
+| --- | --- | --- | --- | --- |
+| macOS 26.6.2 arm64 | Local restricted Wails production build passed | Local `.app` opened with CSP and displayed fixture UI; native window close left controller running and explicit stop terminated it. Two simultaneous windows and a reopened third window showed the fixture UI and one shared controller in a separate temporary user-state root. An earlier diagnostic build showed no resolved screen or clipboard read within 1.5 seconds, without displaying returned data; that build did not distinguish rejection from timeout. A separate navigation-probe build left the fixture UI visible after the loopback attempt in an inspected window capture | User-level copy in `~/Applications` was signed locally, verified, opened with its sibling controller, then removed from Applications; clean-system install remains open | WKWebView supplied by macOS |
+| Windows 2025 CI runner, amd64 | Native Wails shell, controller build and controller tests passed | Not tested | Not tested | WebView2 runtime |
+| Windows 11 Pro build 26200 test workstation, amd64 | Guarded shell and controller cross-built from macOS; transferred EXE hashes matched | Both processes started in the active user session. A later console-session trial rendered two simultaneous fixture windows and a reopened third window with one shared controller. Window-only captures showed `Controlador disponible`; the revised diagnostic showed both direct WebView screen and clipboard calls timed out after 1.5 seconds. At `3b111b9`, a separate navigation-probe build attempted a loopback page; the captured fixture window remained visible after 7 seconds | Temporary per-user EXEs launched and removed; no installer or clean-system test | WebView2 rendered the fixture window |
+| Ubuntu 24.04 CI runner, amd64 | Native Wails shell, controller build and controller tests passed | Passed in virtual X11 with Xvfb. Inspected normal, bridge diagnostic and navigation-probe captures showed fixture UI and `Controlador disponible`. The navigation probe left the packaged page visible after the loopback attempt; screen/clipboard calls timed out after 1.5 seconds. A separate headless Weston session exercised two Wayland shell processes sharing one controller, window-process close and explicit controller stop. Its inspected normal and navigation-probe captures showed the synthetic device list and `Controlador disponible`; the latter also showed the packaged-page survival message after the loopback attempt. Real desktop X11 and Wayland remain open | Not tested | GTK3 and WebKit2GTK 4.1 |
+
+At `9a0ceca`, a locally signed macOS build and sibling controller were launched
+with a temporary `HOME`, so this trial did not use the normal Atenea state.
+Two separate `open -n` launches rendered separate windows; inspected captures
+of each contained the synthetic device list and `Controlador disponible`.
+Process inspection showed two GUI processes and exactly one controller. Closing
+the first window through its native close button ended only that GUI process.
+A third launch rendered the same available-controller state and reused the
+original controller process. Closing the remaining windows left that process
+running; its explicit `--stop` terminated it. The temporary processes and state
+were then removed. This observes one macOS graphical session, not another
+user, Windows/Linux GUI concurrency, sleep, logout or clean-system installation.
+
+At `c33e94f`, a guarded Windows build and sibling controller were transferred
+to a separate temporary directory after SHA-256 comparison. A temporary task
+ran in the logged-in console session with isolated `APPDATA`. Its report
+recorded two GUI processes and exactly one controller. Three window-only
+captures were inspected: the two simultaneous windows and a reopened third
+window all rendered the synthetic device list and `Controlador disponible`.
+Closing the first with the native window-close request left the second and the
+controller running. Reopening reused the same controller process. Closing the
+remaining windows left it running until explicit `--stop`, which terminated it.
+The temporary task, processes and files were removed. This observes one
+Windows graphical session, not another user, Linux GUI concurrency, logout,
+sleep or clean-system installation.
+
+The guarded build matrix passed on native macOS, Windows and Ubuntu runners at
+`2de550f`. The Windows workstation trial used a temporary interactive scheduled task to
+launch the two verified EXEs in the active user session. The capture selected
+only the Atenea window. A second trial used `probe-build`; its original
+`Puente bloqueado` label conflated rejection with a timeout. The revised probe
+showed `Pantalla: sin respuesta · Portapapeles: sin respuesta` after direct
+framework read calls from the Windows WebView. It never displays or records
+returned values. This is observed non-response within 1.5 seconds, not proof
+of an explicit rejection.
+Both preview processes, the two temporary tasks and the temporary directory
+were removed after each trial. The normal build was restored afterward. This
+does not verify every framework operation, remote SSH, credential storage or
+an installer. A separate Windows user-session trial used a temporary
+`navigation-probe-build` based on `3b111b9`. The transferred window and
+controller hashes matched the cross-built binaries. Seven seconds after the
+loopback navigation attempt, a window-only capture still showed the fixture
+UI, the available controller and the probe's survival message. The test
+processes, scheduled task and temporary directory were removed afterward.
+This confirms that the window remained on its packaged page in that trial;
+it does not prove which layer rejected the navigation or cover other
+navigation types. On Ubuntu 24.04 CI at `a6db23f`, Xvfb launched the packaged
+window and a controller in a virtual X11 session. The captured window was
+inspected and visibly contained the fixture list and available controller.
+The CI smoke step checks that a visible window can be captured; visual content
+was verified separately by inspecting the artifact. A second Xvfb run at
+`f183222` captured the diagnostic window. Both direct calls reported `sin
+respuesta` after 1.5 seconds while controller status succeeded. This does not
+prove explicit rejection or exclude a later response. It also does not establish
+behavior in a real Linux desktop session or Wayland. At `0013f86`, another
+Xvfb capture showed the fixture UI and probe survival message after the
+loopback navigation attempt. The CI step captured the window; its visible
+content was inspected separately. At `b31c28d`, Ubuntu CI also ran the normal
+shell twice with `GDK_BACKEND=wayland` and no X11 display, under a temporary
+headless Weston compositor. The lifecycle step observed both shell processes
+alive and one controller process under isolated user state. After ending both
+shell processes, the controller remained until its explicit stop command,
+then exited. This exercises the Wayland client and controller lifecycle in a
+virtual session. That process-only run did not establish visible pixels, user interaction,
+navigation behavior or installation in a real Linux desktop. At `5a9235b`,
+the Ubuntu CI job captured the Weston output after both shell processes had
+started. The PNG was inspected separately: it showed the fixture device list,
+the example-data badge and `Controlador disponible`. The CI step only checks
+that one nonempty PNG was written; it does not interpret the pixels. Weston's
+debug capture was enabled only for the isolated temporary compositor using
+synthetic data. The image shows one foreground window, while the process check
+covers the two simultaneous shells. This still does not test a real desktop,
+user interaction or Wayland navigation policy. At `2758583`, the Ubuntu CI job
+also captured the navigation-probe build under the same virtual Wayland setup,
+seven seconds after its loopback navigation attempt. The inspected 1024×640
+PNG showed the fixture UI, available controller and `La página empaquetada
+sigue visible tras el intento`. The test checks process survival and that a
+PNG exists; the visual content was confirmed separately. This observes that
+the packaged page remained visible, without identifying which browser or
+native layer prevented navigation or covering other navigation types. A CI build
+does not prove a graphical session, WebView behavior, installation or runtime
+availability on a clean user machine. OS logout and sleep/resume remain
+unobserved. Wails' [installation guide](https://wails.io/docs/gettingstarted/installation/)
+lists platform requirements; its [build guide](https://wails.io/docs/gettingstarted/building/)
+documents Ubuntu 24.04's `webkit2_41` tag. The frontend uses the bundled
+Nunito font under the included SIL Open Font License; platform window chrome
+and font rasterization can differ.
diff --git a/desktop/ssh/app.go b/desktop/ssh/app.go
new file mode 100644
index 00000000..29d1b7a7
--- /dev/null
+++ b/desktop/ssh/app.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "time"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/controller"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/handshake"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/localipc"
+)
+
+// App exposes only fixture lifecycle data. Wails framework runtime methods
+// require a separate host-side bridge audit before this becomes production UI.
+type App struct{ mu sync.Mutex }
+
+type ControllerView struct {
+ State string `json:"state"`
+ Detail string `json:"detail"`
+}
+
+// ControllerStatus starts the sibling process on demand. It never takes a path
+// or command from JavaScript and never dispatches a remote operation.
+func (a *App) ControllerStatus() ControllerView {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ root, err := controller.Root()
+ if err != nil {
+ return ControllerView{"error", "No se encuentra el directorio del usuario"}
+ }
+ if err := localipc.ValidateEndpoint(root); errors.Is(err, localipc.ErrEndpointTooLong) {
+ return ControllerView{"error", "La ruta del usuario es demasiado larga para el socket local"}
+ }
+ id, err := controller.InstallationID(root)
+ if err != nil {
+ return ControllerView{"error", "No se puede abrir el estado local"}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+ if status, err := controller.Call(ctx, root, id, "status"); err == nil {
+ return ControllerView{status.State, "Controlador disponible"}
+ } else if errors.Is(err, handshake.ErrVersion) || errors.Is(err, handshake.ErrPeer) {
+ return ControllerView{"incompatible", "Versión o instalación del controlador incompatible"}
+ } else if errors.Is(err, localipc.ErrPeer) || errors.Is(err, localipc.ErrPrivateRoot) {
+ return ControllerView{"error", "No se pudo verificar la identidad del controlador"}
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ return ControllerView{"error", "No se encuentra la instalación"}
+ }
+ name := "atenea-ssh-controller"
+ if runtime.GOOS == "windows" {
+ name += ".exe"
+ }
+ command := exec.Command(filepath.Join(filepath.Dir(executable), name), "--root", root)
+ if err := command.Start(); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ControllerView{"missing", "Falta el controlador en la instalación"}
+ }
+ return ControllerView{"error", "No se pudo iniciar el controlador"}
+ }
+ _ = command.Process.Release()
+ probe := time.NewTicker(100 * time.Millisecond)
+ defer probe.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return ControllerView{"error", "El controlador no respondió a tiempo"}
+ case <-probe.C:
+ if status, err := controller.Call(ctx, root, id, "status"); err == nil {
+ return ControllerView{status.State, "Controlador disponible"}
+ }
+ }
+ }
+}
diff --git a/desktop/ssh/app_unix_test.go b/desktop/ssh/app_unix_test.go
new file mode 100644
index 00000000..d67425f5
--- /dev/null
+++ b/desktop/ssh/app_unix_test.go
@@ -0,0 +1,33 @@
+//go:build darwin || linux
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/controller"
+)
+
+func TestControllerStatusReportsLongSocketPathBeforeStateCreation(t *testing.T) {
+ home := filepath.Join(t.TempDir(), strings.Repeat("x", 104))
+ if runtime.GOOS == "linux" {
+ t.Setenv("XDG_CONFIG_HOME", home)
+ } else {
+ t.Setenv("HOME", home)
+ }
+ root, err := controller.Root()
+ if err != nil {
+ t.Fatal(err)
+ }
+ view := (&App{}).ControllerStatus()
+ if view.State != "error" || !strings.Contains(view.Detail, "demasiado larga") {
+ t.Fatalf("unexpected long-path status: %#v", view)
+ }
+ if _, err := os.Lstat(root); !os.IsNotExist(err) {
+ t.Fatalf("long endpoint created user state: %v", err)
+ }
+}
diff --git a/desktop/ssh/bridge/guard.go.txt b/desktop/ssh/bridge/guard.go.txt
new file mode 100644
index 00000000..d0f4c62d
--- /dev/null
+++ b/desktop/ssh/bridge/guard.go.txt
@@ -0,0 +1,38 @@
+
+// ateneaSSHAllowedMessage is checked before Wails' framework dispatcher. This
+// fixture shell needs only its zero-argument controller status binding.
+func ateneaSSHAllowedMessage(message string) bool {
+ // Native window close on macOS/Linux is delivered to this dispatcher as Q.
+ // A page can also request quit; it cannot reach controller state by doing so.
+ if message == "Q" { return true }
+ if len(message) < 2 || len(message) > 4096 || message[0] != 'C' {
+ return false
+ }
+ var payload map[string]json.RawMessage
+ if json.Unmarshal([]byte(message[1:]), &payload) != nil || len(payload) != 3 {
+ return false
+ }
+ var name, callbackID string
+ var args []json.RawMessage
+ if json.Unmarshal(payload["name"], &name) != nil || name != "main.App.ControllerStatus" {
+ return false
+ }
+ if json.Unmarshal(payload["args"], &args) != nil || len(args) != 0 {
+ return false
+ }
+ if json.Unmarshal(payload["callbackID"], &callbackID) != nil {
+ return false
+ }
+ // Wails generates this ID from the binding name and a numeric random value.
+ // Restrict it before the dispatcher can include it in a trace message.
+ const prefix = "main.App.ControllerStatus-"
+ if len(callbackID) <= len(prefix) || len(callbackID) > len(prefix)+32 || callbackID[:len(prefix)] != prefix {
+ return false
+ }
+ for _, c := range callbackID[len(prefix):] {
+ if c < '0' || c > '9' {
+ if c != '.' && c != 'e' && c != '+' && c != '-' { return false }
+ }
+ }
+ return true
+}
diff --git a/desktop/ssh/bridge/guard_test.go.txt b/desktop/ssh/bridge/guard_test.go.txt
new file mode 100644
index 00000000..1d37908a
--- /dev/null
+++ b/desktop/ssh/bridge/guard_test.go.txt
@@ -0,0 +1,35 @@
+package dispatcher
+
+import "testing"
+
+func TestAteneaSSHBridgeGuard(t *testing.T) {
+ allowed := `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-123"}`
+ if !ateneaSSHAllowedMessage(allowed) { t.Fatal("fixture status binding blocked") }
+ if !ateneaSSHAllowedMessage(`C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-1.2e+10"}`) { t.Fatal("fallback runtime callback blocked") }
+ if !ateneaSSHAllowedMessage("Q") { t.Fatal("native close blocked") }
+ for _, message := range []string{
+ `BOpen:https://example.invalid`,
+ `Wz`, `S`, `H`, `Ddrop`, `LSecret`, `Eevent`,
+ `C{"name":"wails.ClipboardGetText","args":[],"callbackID":"1"}`,
+ `C{"name":"wails.Environment","args":[],"callbackID":"1"}`,
+ `C{"name":"main.App.ControllerStatus","args":["/tmp"],"callbackID":"1"}`,
+ `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"1","extra":true}`,
+ `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-secret"}`,
+ `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-123\nsecret"}`,
+ `c{"id":1,"args":[],"callbackID":"1"}`,
+ } {
+ if ateneaSSHAllowedMessage(message) { t.Fatalf("unauthorized bridge message accepted: %s", message) }
+ }
+}
+
+func TestAteneaSSHDispatcherSilentlyDropsDirectMessages(t *testing.T) {
+ var d Dispatcher
+ for _, message := range []string{
+ `BO:https://example.invalid`, `Wz`, `S`, `H`, `Ddrop`,
+ `C{"name":"wails.ClipboardGetText","args":[],"callbackID":"1"}`,
+ `C{"name":"wails.Environment","args":[],"callbackID":"1"}`,
+ `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-secret"}`,
+ } {
+ if result, err := d.ProcessMessage(message, nil); result != "" || err != nil { t.Fatalf("dispatcher did not drop message: result=%q err=%v", result, err) }
+ }
+}
diff --git a/desktop/ssh/bridge/ingress.go.txt b/desktop/ssh/bridge/ingress.go.txt
new file mode 100644
index 00000000..e2341801
--- /dev/null
+++ b/desktop/ssh/bridge/ingress.go.txt
@@ -0,0 +1,6 @@
+
+// Permit lifecycle signals and the single bound-call channel. All other
+// JavaScript messages are rejected before framework special handlers.
+func ateneaSSHIngressMessage(message string) bool {
+ return message == "DomReady" || message == "runtime:ready" || message == "Q" || (len(message) >= 2 && len(message) <= 4096 && message[0] == 'C')
+}
diff --git a/desktop/ssh/bridge/ingress_test.go.txt b/desktop/ssh/bridge/ingress_test.go.txt
new file mode 100644
index 00000000..5e83df75
--- /dev/null
+++ b/desktop/ssh/bridge/ingress_test.go.txt
@@ -0,0 +1,16 @@
+package PLATFORM
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAteneaSSHIngressRejectsRuntimeBypass(t *testing.T) {
+ for _, message := range []string{"DomReady", "runtime:ready", "Q", `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"1"}`} {
+ if !ateneaSSHIngressMessage(message) { t.Fatalf("required lifecycle or call message blocked: %s", message) }
+ }
+ if !ateneaSSHIngressMessage("C" + strings.Repeat("x", 4095)) { t.Fatal("maximum-size call message blocked at ingress") }
+ for _, message := range []string{"", "C", "drag", "resize:left", "wails:showInspector", "wails:openInspector", "file:drop:0:0", "BOpen:https://example.invalid", "Ddrop", "Wz", "C" + strings.Repeat("x", 4096)} {
+ if ateneaSSHIngressMessage(message) { t.Fatalf("framework bypass accepted: %s", message) }
+ }
+}
diff --git a/desktop/ssh/bridge/navigation_darwin.m.txt b/desktop/ssh/bridge/navigation_darwin.m.txt
new file mode 100644
index 00000000..180f8ffc
--- /dev/null
+++ b/desktop/ssh/bridge/navigation_darwin.m.txt
@@ -0,0 +1,14 @@
+- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)action
+ decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
+ NSURL *url = action.request.URL;
+ BOOL packaged = url != nil &&
+ [[url scheme] isEqualToString:@"wails"] &&
+ [[url host] isEqualToString:@"wails"] &&
+ [url port] == nil && [url user] == nil &&
+ [[url path] isEqualToString:@"/"];
+ if (!packaged || action.targetFrame == nil || !action.targetFrame.isMainFrame) {
+ decisionHandler(WKNavigationActionPolicyCancel);
+ return;
+ }
+ decisionHandler(WKNavigationActionPolicyAllow);
+}
diff --git a/desktop/ssh/bridge/navigation_linux.c.txt b/desktop/ssh/bridge/navigation_linux.c.txt
new file mode 100644
index 00000000..881a50be
--- /dev/null
+++ b/desktop/ssh/bridge/navigation_linux.c.txt
@@ -0,0 +1,24 @@
+static gboolean ateneaNavigationPolicy(WebKitWebView *webview, WebKitPolicyDecision *decision,
+ WebKitPolicyDecisionType type, gpointer data)
+{
+ if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION)
+ {
+ webkit_policy_decision_ignore(decision);
+ return TRUE;
+ }
+ if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
+ {
+ return FALSE;
+ }
+ WebKitNavigationPolicyDecision *navigation = WEBKIT_NAVIGATION_POLICY_DECISION(decision);
+ WebKitNavigationAction *action = webkit_navigation_policy_decision_get_navigation_action(navigation);
+ WebKitURIRequest *request = action == NULL ? NULL : webkit_navigation_action_get_request(action);
+ const gchar *uri = request == NULL ? NULL : webkit_uri_request_get_uri(request);
+ if (g_strcmp0(uri, "wails://wails/") == 0 ||
+ (uri != NULL && g_str_has_prefix(uri, "wails://wails/#")))
+ {
+ return FALSE;
+ }
+ webkit_policy_decision_ignore(decision);
+ return TRUE;
+}
diff --git a/desktop/ssh/bridge/navigation_windows.go.txt b/desktop/ssh/bridge/navigation_windows.go.txt
new file mode 100644
index 00000000..f094f22a
--- /dev/null
+++ b/desktop/ssh/bridge/navigation_windows.go.txt
@@ -0,0 +1,124 @@
+package edge
+
+import (
+ "strings"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+// Keep these callbacks in the reviewed edge package. The older pkg/webview2
+// package has unrelated callback initializers that fail under Go 1.26.
+type ateneaNavigationArgsVtbl struct {
+ _IUnknownVtbl
+ GetUri ComProc
+ GetIsUserInitiated ComProc
+ GetIsRedirected ComProc
+ GetRequestHeaders ComProc
+ GetCancel ComProc
+ PutCancel ComProc
+ GetNavigationId ComProc
+}
+
+type ateneaNavigationArgs struct{ vtbl *ateneaNavigationArgsVtbl }
+
+type ateneaNewWindowArgsVtbl struct {
+ _IUnknownVtbl
+ GetUri ComProc
+ PutNewWindow ComProc
+ GetNewWindow ComProc
+ PutHandled ComProc
+ GetHandled ComProc
+ GetIsUserInitiated ComProc
+ GetDeferral ComProc
+ GetWindowFeatures ComProc
+}
+
+type ateneaNewWindowArgs struct{ vtbl *ateneaNewWindowArgsVtbl }
+
+type ateneaNavigationHandlerVtbl struct {
+ _IUnknownVtbl
+ Invoke ComProc
+}
+
+type ateneaNavigationHandler struct {
+ vtbl *ateneaNavigationHandlerVtbl
+ owner *Chromium
+}
+
+type ateneaNewWindowHandlerVtbl struct {
+ _IUnknownVtbl
+ Invoke ComProc
+}
+
+type ateneaNewWindowHandler struct {
+ vtbl *ateneaNewWindowHandlerVtbl
+ owner *Chromium
+}
+
+var ateneaNavigationVtable = ateneaNavigationHandlerVtbl{
+ _IUnknownVtbl{
+ NewComProc(func(h *ateneaNavigationHandler, iid, object uintptr) uintptr { return h.owner.QueryInterface(iid, object) }),
+ NewComProc(func(h *ateneaNavigationHandler) uintptr { return h.owner.AddRef() }),
+ NewComProc(func(h *ateneaNavigationHandler) uintptr { return h.owner.Release() }),
+ },
+ NewComProc(func(h *ateneaNavigationHandler, _ *ICoreWebView2, args *ateneaNavigationArgs) uintptr {
+ return h.owner.ateneaNavigationStarting(args)
+ }),
+}
+
+var ateneaNewWindowVtable = ateneaNewWindowHandlerVtbl{
+ _IUnknownVtbl{
+ NewComProc(func(h *ateneaNewWindowHandler, iid, object uintptr) uintptr { return h.owner.QueryInterface(iid, object) }),
+ NewComProc(func(h *ateneaNewWindowHandler) uintptr { return h.owner.AddRef() }),
+ NewComProc(func(h *ateneaNewWindowHandler) uintptr { return h.owner.Release() }),
+ },
+ NewComProc(func(h *ateneaNewWindowHandler, _ *ICoreWebView2, args *ateneaNewWindowArgs) uintptr {
+ return h.owner.ateneaNewWindowRequested(args)
+ }),
+}
+
+func ateneaAllowedNavigation(uri string) bool {
+ return uri == "http://wails.localhost/" || strings.HasPrefix(uri, "http://wails.localhost/#")
+}
+
+func (e *Chromium) ateneaNavigationStarting(args *ateneaNavigationArgs) uintptr {
+ var rawURI *uint16
+ hr, _, _ := args.vtbl.GetUri.Call(uintptr(unsafe.Pointer(args)), uintptr(unsafe.Pointer(&rawURI)))
+ allowed := false
+ if windows.Handle(hr) == windows.S_OK && rawURI != nil {
+ allowed = ateneaAllowedNavigation(windows.UTF16PtrToString(rawURI))
+ windows.CoTaskMemFree(unsafe.Pointer(rawURI))
+ }
+ if !allowed {
+ hr, _, _ = args.vtbl.PutCancel.Call(uintptr(unsafe.Pointer(args)), 1)
+ if windows.Handle(hr) != windows.S_OK {
+ e.errorCallback(windows.Errno(hr))
+ }
+ }
+ return 0
+}
+
+func (e *Chromium) ateneaNewWindowRequested(args *ateneaNewWindowArgs) uintptr {
+ hr, _, _ := args.vtbl.PutHandled.Call(uintptr(unsafe.Pointer(args)), 1)
+ if windows.Handle(hr) != windows.S_OK {
+ e.errorCallback(windows.Errno(hr))
+ }
+ return 0
+}
+
+func (e *Chromium) ateneaRegisterNavigation() {
+ var token _EventRegistrationToken
+ hr, _, _ := e.webview.vtbl.AddNavigationStarting.Call(
+ uintptr(unsafe.Pointer(e.webview)), uintptr(unsafe.Pointer(e.navigationStarting)), uintptr(unsafe.Pointer(&token)),
+ )
+ if windows.Handle(hr) != windows.S_OK {
+ e.errorCallback(windows.Errno(hr))
+ }
+ hr, _, _ = e.webview.vtbl.AddNewWindowRequested.Call(
+ uintptr(unsafe.Pointer(e.webview)), uintptr(unsafe.Pointer(e.newWindowRequested)), uintptr(unsafe.Pointer(&token)),
+ )
+ if windows.Handle(hr) != windows.S_OK {
+ e.errorCallback(windows.Errno(hr))
+ }
+}
diff --git a/desktop/ssh/bridge/navigation_windows_test.go.txt b/desktop/ssh/bridge/navigation_windows_test.go.txt
new file mode 100644
index 00000000..e29137d9
--- /dev/null
+++ b/desktop/ssh/bridge/navigation_windows_test.go.txt
@@ -0,0 +1,27 @@
+package edge
+
+import "testing"
+
+func TestAteneaAllowedNavigation(t *testing.T) {
+ tests := []struct {
+ uri string
+ want bool
+ }{
+ {"http://wails.localhost/", true},
+ {"http://wails.localhost/#ssh", true},
+ {"http://wails.localhost/other", false},
+ {"http://wails.localhost/?other", false},
+ {"https://wails.localhost/", false},
+ {"http://wails.localhost.evil/", false},
+ {"http://user@wails.localhost/", false},
+ {"http://wails.localhost:8080/", false},
+ {"file:///C:/secret", false},
+ {"data:text/html,hello", false},
+ {"about:blank", false},
+ }
+ for _, tt := range tests {
+ if got := ateneaAllowedNavigation(tt.uri); got != tt.want {
+ t.Errorf("ateneaAllowedNavigation(%q) = %t, want %t", tt.uri, got, tt.want)
+ }
+ }
+}
diff --git a/desktop/ssh/build/appicon.png b/desktop/ssh/build/appicon.png
new file mode 100644
index 00000000..63617fe4
Binary files /dev/null and b/desktop/ssh/build/appicon.png differ
diff --git a/desktop/ssh/build/darwin/Info.plist b/desktop/ssh/build/darwin/Info.plist
new file mode 100644
index 00000000..ac1ca2d1
--- /dev/null
+++ b/desktop/ssh/build/darwin/Info.plist
@@ -0,0 +1,63 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ dev.tutitoos.atenea.ssh
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.15.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/desktop/ssh/frontend/bun.lock b/desktop/ssh/frontend/bun.lock
new file mode 100644
index 00000000..4d6a98f3
--- /dev/null
+++ b/desktop/ssh/frontend/bun.lock
@@ -0,0 +1,259 @@
+{
+ "lockfileVersion": 2,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "atenea-ssh-desktop",
+ "dependencies": {
+ "react": "19.1.1",
+ "react-dom": "19.1.1",
+ },
+ "devDependencies": {
+ "@types/react": "19.1.13",
+ "@types/react-dom": "19.1.9",
+ "@vitejs/plugin-react": "5.0.0",
+ "typescript": "5.9.2",
+ "vite": "7.0.6",
+ },
+ },
+ },
+ "packages": {
+ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
+
+ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
+
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
+
+ "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
+
+ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
+
+ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
+
+ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.30", "", {}, "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.4", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.4", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.4", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
+
+ "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+
+ "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+
+ "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+
+ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
+
+ "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
+
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.30", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="],
+
+ "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.433", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="],
+
+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
+
+ "node-releases": ["node-releases@2.0.56", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+
+ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
+ "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
+
+ "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
+
+ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
+
+ "rollup": ["rollup@4.63.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
+
+ "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
+
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
+
+ "vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
+
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ }
+}
diff --git a/desktop/ssh/frontend/index.html b/desktop/ssh/frontend/index.html
new file mode 100644
index 00000000..0053824b
--- /dev/null
+++ b/desktop/ssh/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Atenea SSH
+
+
+
+
+
+
diff --git a/desktop/ssh/frontend/package.json b/desktop/ssh/frontend/package.json
new file mode 100644
index 00000000..a6b027ce
--- /dev/null
+++ b/desktop/ssh/frontend/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "atenea-ssh-desktop",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "check": "tsc --noEmit",
+ "build": "tsc --noEmit && vite build"
+ },
+ "dependencies": {
+ "react": "19.1.1",
+ "react-dom": "19.1.1"
+ },
+ "devDependencies": {
+ "@types/react": "19.1.13",
+ "@types/react-dom": "19.1.9",
+ "@vitejs/plugin-react": "5.0.0",
+ "typescript": "5.9.2",
+ "vite": "7.0.6"
+ }
+}
diff --git a/desktop/ssh/frontend/src/App.tsx b/desktop/ssh/frontend/src/App.tsx
new file mode 100644
index 00000000..5696fc45
--- /dev/null
+++ b/desktop/ssh/frontend/src/App.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useMemo, useState } from 'react'
+
+type ControllerView = { state: string; detail: string }
+type Host = { alias: string; address: string; platform: string; agent: string; tone: 'ready' | 'pending' | 'error' }
+type ProbeOutcome = 'resolved' | 'rejected' | 'timeout' | 'unavailable'
+type ProbeView = { detail: string; tone: 'pending' | 'safe' | 'warning' }
+
+declare global {
+ interface Window {
+ go?: { main?: { App?: { ControllerStatus: () => Promise } } }
+ runtime?: { ScreenGetAll?: () => Promise; ClipboardGetText?: () => Promise }
+ WailsInvoke?: (message: string) => void
+ }
+}
+
+const hosts: Host[] = [
+ { alias: 'equipo-diseno', address: '192.0.2.12', platform: 'macOS', agent: 'Agente disponible', tone: 'ready' },
+ { alias: 'servidor-pruebas', address: '192.0.2.24', platform: 'Linux', agent: 'Sin instalar', tone: 'pending' },
+ { alias: 'estacion-taller', address: '192.0.2.36', platform: 'Windows', agent: 'Conexión pendiente', tone: 'error' },
+]
+
+const activity = [
+ { time: '10:42', title: 'Sesión de ejemplo iniciada', detail: 'equipo-diseno · Vista previa' },
+ { time: 'Ayer', title: 'Estado del agente consultado', detail: 'servidor-pruebas · Vista previa' },
+]
+
+function App() {
+ const [query, setQuery] = useState('')
+ const [selected, setSelected] = useState(hosts[0].alias)
+ const [visibility, setVisibility] = useState<'visible' | 'oculto'>('visible')
+ const [controller, setController] = useState({ state: 'checking', detail: 'Comprobando controlador…' })
+ const [bridgeProbe, setBridgeProbe] = useState({ detail: 'Comprobando puente…', tone: 'pending' })
+ const [navigationProbe, setNavigationProbe] = useState('Preparando intento de navegación…')
+ const [theme, setTheme] = useState<'light' | 'dark'>(() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
+ const filtered = useMemo(() => hosts.filter(host => `${host.alias} ${host.address} ${host.platform}`.toLowerCase().includes(query.toLowerCase())), [query])
+ const host = hosts.find(item => item.alias === selected) ?? hosts[0]
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme
+ }, [theme])
+
+ useEffect(() => {
+ const status = window.go?.main?.App?.ControllerStatus
+ if (!status) { setController({ state: 'preview', detail: 'Vista previa en navegador' }); return }
+ void status().then(setController).catch(() => setController({ state: 'error', detail: 'No se pudo consultar el controlador' }))
+ }, [])
+
+ useEffect(() => {
+ if (import.meta.env.VITE_ATENEA_BRIDGE_PROBE !== '1') return
+ // A direct framework command must not change the native window title.
+ window.WailsInvoke?.('WTAtenea SSH probe escaped')
+ if (!window.runtime) { setBridgeProbe({ detail: 'Runtime no disponible', tone: 'warning' }); return }
+ const check = async (call: (() => Promise) | undefined): Promise => {
+ if (!call) return 'unavailable'
+ // Never inspect or render a framework result, which could contain local data.
+ try {
+ return await Promise.race([
+ Promise.resolve().then(call).then(() => 'resolved', () => 'rejected'),
+ new Promise(resolve => window.setTimeout(() => resolve('timeout'), 1500)),
+ ])
+ } catch { return 'rejected' }
+ }
+ void Promise.all([
+ check(window.runtime?.ScreenGetAll),
+ check(window.runtime?.ClipboardGetText),
+ ]).then(([screen, clipboard]) => {
+ const outcomes: Record = {
+ resolved: 'respondió', rejected: 'rechazada', timeout: 'sin respuesta', unavailable: 'no disponible',
+ }
+ setBridgeProbe({
+ detail: `Pantalla: ${outcomes[screen]} · Portapapeles: ${outcomes[clipboard]}`,
+ tone: screen === 'rejected' && clipboard === 'rejected' ? 'safe' : 'warning',
+ })
+ })
+ }, [])
+
+ useEffect(() => {
+ if (import.meta.env.VITE_ATENEA_NAVIGATION_PROBE !== '1') return
+ const timer = window.setTimeout(() => {
+ try {
+ window.location.assign('http://127.0.0.1:9/atenea-navigation-probe')
+ window.setTimeout(() => setNavigationProbe('La página empaquetada sigue visible tras el intento'), 1500)
+ } catch {
+ setNavigationProbe('El navegador rechazó el intento de navegación')
+ }
+ }, 1000)
+ return () => window.clearTimeout(timer)
+ }, [])
+
+ return
+
+
+
+
+ {import.meta.env.VITE_ATENEA_BRIDGE_PROBE === '1' &&
{bridgeProbe.detail}
}
+ {import.meta.env.VITE_ATENEA_NAVIGATION_PROBE === '1' &&
{navigationProbe}
}
+
CONEXIONES SSH
Tus dispositivos
Consulta el estado de tus equipos y elige dónde trabajar.
DATOS DE EJEMPLO
+
Controlador local · {controller.detail}
+
Dispositivos
{filtered.length} de {hosts.length}{filtered.map(item =>
)}{filtered.length === 0 &&
No hay dispositivos con ese nombre.
}
+
▣{host.agent}
{host.alias}
{host.address} · {host.platform}
Alias SSH{host.alias}
Estado del agente{host.agent}
Conversación{visibility === 'visible' ? 'Visible' : 'Oculta'}
Visibilidad del chatElige cómo aparecerá la conversación en este equipo.
Esta vista no abre conexiones SSH ni guarda preferencias.
+
ACTIVIDAD
Historial reciente
Vista previa {activity.map(item => ◷{item.title}{item.detail}
)}
+
+
+
+}
+
+export default App
diff --git a/desktop/ssh/frontend/src/assets/fonts/OFL.txt b/desktop/ssh/frontend/src/assets/fonts/OFL.txt
new file mode 100644
index 00000000..5843e21b
--- /dev/null
+++ b/desktop/ssh/frontend/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
new file mode 100644
index 00000000..2f9cc596
Binary files /dev/null and b/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ
diff --git a/desktop/ssh/frontend/src/main.tsx b/desktop/ssh/frontend/src/main.tsx
new file mode 100644
index 00000000..3626ff30
--- /dev/null
+++ b/desktop/ssh/frontend/src/main.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+import {createRoot} from 'react-dom/client'
+import './style.css'
+import App from './App'
+
+const container = document.getElementById('root')
+
+const root = createRoot(container!)
+
+root.render(
+
+
+
+)
diff --git a/desktop/ssh/frontend/src/style.css b/desktop/ssh/frontend/src/style.css
new file mode 100644
index 00000000..380d75b5
--- /dev/null
+++ b/desktop/ssh/frontend/src/style.css
@@ -0,0 +1,10 @@
+@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2') format('woff2');font-display:swap}
+:root{font-family:Nunito,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17212f;background:#f6f7f9;font-synthesis:none}
+:root[data-theme="dark"]{color:#e9edf4;background:#10151d}
+*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #7d9de5;outline-offset:2px}button:disabled{cursor:not-allowed}
+.shell{display:flex;min-height:100vh}.sidebar{width:228px;flex:none;background:#182333;color:#d9e2f2;display:flex;flex-direction:column;padding:29px 13px 18px}.brand{display:flex;align-items:center;gap:11px;padding:0 13px 42px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;background:#6f8de2;border-radius:10px;color:white;font-size:24px}.brand strong,.brand small,.sidebar-bottom strong,.sidebar-bottom small{display:block}.brand strong{font-size:16px;color:#fff}.brand small,.sidebar-bottom small{font-size:11px;color:#a1afc1}.sidebar-label,.eyebrow{font-size:10px;letter-spacing:.14em;font-weight:800}.sidebar-label{color:#8495aa;padding:0 14px 13px}.nav-item{border:0;background:none;color:#aebbd0;width:100%;padding:12px 14px;text-align:left;border-radius:9px;display:flex;align-items:center;gap:13px;font-weight:700;font-size:13px}.nav-item span:first-child{font-size:17px}.nav-item.active{background:#34415d;color:#fff}.nav-count{margin-left:auto;color:#c9d6f2}.sidebar-bottom{margin-top:auto;border-top:1px solid #344154;padding:18px 11px 0;display:flex;align-items:center;gap:10px;font-size:12px}.avatar{display:grid;place-items:center;background:#6b80bf;color:#fff;border-radius:50%;width:30px;height:30px;font-weight:800}
+.bridge-probe{background:#fff3d9;color:#765000;border:1px solid #e9c775;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;font-weight:800}.bridge-probe[data-tone="safe"]{background:#dff4e9;color:#166843;border-color:#91d8b0}
+.search:focus-within{outline:3px solid #7d9de5;outline-offset:2px}
+.workspace{flex:1;min-width:0}.topbar{height:65px;border-bottom:1px solid #e4e8ed;background:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px}.breadcrumb{color:#909baa}.slash{margin:0 12px;color:#b5bfcb}.theme-button{border:1px solid #e3e7ed;background:#fff;border-radius:8px;width:30px;height:30px;color:#5e6b80}.content{max-width:1160px;margin:auto;padding:42px 38px}.heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:24px}.eyebrow{color:#687fc2}.heading h1{margin:7px 0 6px;font-size:29px;letter-spacing:-.04em}.heading p{margin:0;color:#768394;font-size:13px}.fixture-badge{background:#e9eefb;color:#5269a9;border:1px solid #d8e1f8;border-radius:5px;font-weight:800;font-size:9px;letter-spacing:.1em;padding:8px 10px;white-space:nowrap}.controller-state{display:flex;align-items:center;gap:9px;background:#eef3fb;border:1px solid #dae5f4;border-radius:10px;padding:12px 15px;margin-bottom:20px;font-size:12px;color:#546176}.controller-state strong{color:#314159}.state-dot,.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#d5a752;flex:none}.state-dot.running,.status-dot.ready{background:#42b888}.state-dot.error,.state-dot.missing,.status-dot.error{background:#dc806f}.columns{display:grid;grid-template-columns:minmax(290px,.9fr) minmax(340px,1.1fr);gap:18px}.list-panel,.detail-panel,.history{background:#fff;border:1px solid #e4e8ee;border-radius:12px;box-shadow:0 5px 18px rgba(24,39,58,.035)}.list-panel{padding:20px 14px}.panel-heading{display:flex;justify-content:space-between;align-items:center;padding:0 8px 16px}.panel-heading h2,.detail-panel h2,.history h2{margin:0;font-size:16px;letter-spacing:-.02em}.panel-heading span,.history-heading>span{color:#96a0ad;font-size:11px}.search{height:39px;border:1px solid #e0e5ec;background:#f8f9fb;border-radius:8px;display:flex;align-items:center;padding:0 11px;gap:8px;color:#9aa6b5}.search span{font-size:22px;line-height:0}.search input{background:transparent;border:0;outline:0;width:100%;font-size:12px;color:inherit}.host-list{padding-top:13px}.host-row{display:flex;align-items:center;gap:11px;width:100%;text-align:left;border:1px solid transparent;background:none;padding:12px;border-radius:9px;margin-bottom:3px;color:inherit}.host-row:hover{background:#f5f7fb}.host-row.selected{background:#ecf1fc;border-color:#d9e3fa}.device-icon,.large-device{display:grid;place-items:center;background:#e8eef9;color:#607bc0;border-radius:9px;font-size:21px;width:37px;height:37px;flex:none}.host-copy{flex:1;min-width:0}.host-copy strong,.host-copy small,.activity-row strong,.activity-row small{display:block}.host-copy strong{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-copy small{font-size:10px;color:#93a0af;margin-top:3px}.empty{font-size:12px;color:#8491a1;text-align:center;padding:20px}.detail-panel{padding:23px}.detail-top{display:flex;justify-content:space-between;align-items:flex-start}.large-device{width:46px;height:46px;font-size:26px}.status-pill{background:#e4f7ed;color:#268661;padding:6px 9px;border-radius:5px;font-size:10px;font-weight:800}.status-pill.pending{background:#fff4dd;color:#a9741a}.status-pill.error{background:#fff0ec;color:#af6154}.detail-panel h2{margin-top:19px;font-size:19px}.device-address{margin:3px 0 0;color:#94a0af;font-size:11px}.divider{height:1px;background:#e9edf2;margin:23px 0}.meta{display:grid;gap:14px}.meta>div{display:flex;justify-content:space-between;gap:10px;font-size:11px}.meta span{color:#94a0af}.meta strong{font-weight:700}.visibility{margin-top:25px;background:#f7f9fc;border:1px solid #e9edf3;border-radius:9px;padding:14px}.visibility strong,.visibility small{display:block}.visibility strong{font-size:11px}.visibility small{color:#929dad;font-size:10px;margin:3px 0 14px}.segmented{display:flex;background:#e8edf4;border-radius:7px;padding:3px}.segmented button{width:50%;border:0;background:transparent;color:#778599;padding:6px;border-radius:5px;font-size:11px}.segmented button[aria-pressed="true"]{background:#fff;color:#304c91;box-shadow:0 1px 4px #cbd5e2}.connect{margin-top:20px;border:0;background:#788fcb;color:#fff;width:100%;border-radius:8px;padding:11px 14px;text-align:left;font-weight:800;font-size:11px;opacity:.65}.connect span{float:right}.fixture-note{color:#9ca7b4;text-align:center;font-size:10px;margin:10px 0 0}.history{margin-top:23px;padding:21px 24px}.history-heading{display:flex;justify-content:space-between;align-items:end;padding-bottom:15px}.history h2{margin-top:5px}.activity-row{border-top:1px solid #eef0f3;display:flex;align-items:center;gap:11px;padding:13px 0;font-size:11px}.activity-icon{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:#f0f2f7;color:#8793aa;font-size:18px}.activity-row>div{flex:1}.activity-row small{font-size:10px;color:#9ca7b4;margin-top:2px}.activity-row time{font-size:10px;color:#a1aab6}
+:root[data-theme="dark"] .workspace{background:#10151d}:root[data-theme="dark"] .topbar,:root[data-theme="dark"] .list-panel,:root[data-theme="dark"] .detail-panel,:root[data-theme="dark"] .history{background:#1b2532;border-color:#334151}:root[data-theme="dark"] .theme-button,:root[data-theme="dark"] .search,:root[data-theme="dark"] .visibility{background:#253142;color:#dce5f0;border-color:#38475b}:root[data-theme="dark"] .host-row:hover,:root[data-theme="dark"] .host-row.selected,:root[data-theme="dark"] .controller-state{background:#293955;border-color:#425675;color:#dce5f0}:root[data-theme="dark"] .controller-state strong{color:#f0f4fb}:root[data-theme="dark"] .divider,:root[data-theme="dark"] .activity-row{border-color:#344052}:root[data-theme="dark"] .divider{background:#344052}:root[data-theme="dark"] .segmented{background:#344052}:root[data-theme="dark"] .segmented button[aria-pressed="true"]{background:#526994;color:white}
+@media(max-width:920px){.sidebar{width:65px}.brand{padding:0 3px 40px}.brand>span:last-child{display:none}.sidebar-label,.nav-item{font-size:0}.nav-item{justify-content:center}.nav-item span:first-child{font-size:20px}.nav-count,.sidebar-bottom>div{display:none}.sidebar-bottom{justify-content:center;padding:16px 0 0}.content{padding:30px 22px}.topbar{padding:0 22px}}@media(max-width:700px){.columns{grid-template-columns:1fr}.sidebar{width:54px}.heading{display:block}.fixture-badge{display:inline-block;margin-top:14px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
diff --git a/desktop/ssh/frontend/src/vite-env.d.ts b/desktop/ssh/frontend/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/desktop/ssh/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/desktop/ssh/frontend/tsconfig.json b/desktop/ssh/frontend/tsconfig.json
new file mode 100644
index 00000000..823e83d1
--- /dev/null
+++ b/desktop/ssh/frontend/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/desktop/ssh/frontend/tsconfig.node.json b/desktop/ssh/frontend/tsconfig.node.json
new file mode 100644
index 00000000..b8afcc8f
--- /dev/null
+++ b/desktop/ssh/frontend/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
diff --git a/desktop/ssh/frontend/vite.config.ts b/desktop/ssh/frontend/vite.config.ts
new file mode 100644
index 00000000..49550655
--- /dev/null
+++ b/desktop/ssh/frontend/vite.config.ts
@@ -0,0 +1,7 @@
+import {defineConfig} from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/desktop/ssh/go.mod b/desktop/ssh/go.mod
new file mode 100644
index 00000000..29fab8ca
--- /dev/null
+++ b/desktop/ssh/go.mod
@@ -0,0 +1,42 @@
+module github.com/Tutitoos/atenea/desktop/ssh
+
+go 1.25.13
+
+require (
+ github.com/Tutitoos/atenea v0.0.0
+ github.com/wailsapp/wails/v2 v2.15.0
+)
+
+require (
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+)
+
+replace github.com/Tutitoos/atenea => ../..
diff --git a/desktop/ssh/go.sum b/desktop/ssh/go.sum
new file mode 100644
index 00000000..b78a7144
--- /dev/null
+++ b/desktop/ssh/go.sum
@@ -0,0 +1,84 @@
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
+github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/desktop/ssh/main.go b/desktop/ssh/main.go
new file mode 100644
index 00000000..f99ad1b7
--- /dev/null
+++ b/desktop/ssh/main.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "embed"
+ "log"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+// The build fails if the reviewed host-side Wails overlay is missing.
+var _ = options.AteneaSSHBridgeGuard
+
+func main() {
+ app := &App{}
+ if err := wails.Run(&options.App{
+ Title: "Atenea SSH",
+ Width: 1100,
+ Height: 720,
+ MinWidth: 760,
+ MinHeight: 540,
+ AssetServer: &assetserver.Options{Assets: assets},
+ BackgroundColour: &options.RGBA{R: 246, G: 247, B: 249, A: 1},
+ Bind: []interface{}{app},
+ }); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/desktop/ssh/scripts/linux_launcher.sh b/desktop/ssh/scripts/linux_launcher.sh
new file mode 100644
index 00000000..430eae82
--- /dev/null
+++ b/desktop/ssh/scripts/linux_launcher.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+set -eu
+
+launcher=$(command -v "$0")
+case "$launcher" in
+ /*) ;;
+ *) launcher=$(pwd)/$launcher ;;
+esac
+links=0
+while [ -L "$launcher" ]; do
+ links=$((links + 1))
+ if [ "$links" -gt 16 ]; then
+ printf '%s\n' 'Atenea SSH: el enlace del lanzador tiene demasiados niveles. Reinstala el paquete completo.' >&2
+ exit 78
+ fi
+ target=$(readlink "$launcher") || exit 78
+ case "$target" in
+ /*) launcher=$target ;;
+ *) launcher=$(dirname -- "$launcher")/$target ;;
+ esac
+done
+binary=$(dirname -- "$launcher")/atenea-ssh-bin
+if [ ! -x "$binary" ]; then
+ printf '%s\n' 'Atenea SSH: falta el ejecutable de la ventana junto al lanzador. Reinstala el paquete completo.' >&2
+ exit 78
+fi
+
+wayland_socket=''
+if [ -n "${WAYLAND_DISPLAY:-}" ]; then
+ case "$WAYLAND_DISPLAY" in
+ /*) wayland_socket=$WAYLAND_DISPLAY ;;
+ *) wayland_socket=${XDG_RUNTIME_DIR:-}/$WAYLAND_DISPLAY ;;
+ esac
+fi
+if { [ -z "${DISPLAY:-}" ] || [ "${GDK_BACKEND:-}" = wayland ]; } &&
+ { [ -z "$wayland_socket" ] || [ ! -S "$wayland_socket" ]; } ||
+ { [ "${GDK_BACKEND:-}" = x11 ] && [ -z "${DISPLAY:-}" ]; }; then
+ printf '%s\n' 'Atenea SSH: no hay una sesión gráfica X11 o Wayland disponible. Inicia sesión en el escritorio de este usuario y vuelve a abrir la aplicación.' >&2
+ exit 69
+fi
+
+if ! command -v ldd >/dev/null 2>&1; then
+ printf '%s\n' 'Atenea SSH: no se puede comprobar las bibliotecas gráficas (falta ldd).' >&2
+ exit 69
+fi
+ldd_status=0
+dependencies=$(ldd "$binary" 2>&1) || ldd_status=$?
+missing=$(printf '%s\n' "$dependencies" | awk '/not found/ { print $1 }')
+if [ -n "$missing" ]; then
+ printf '%s\n' 'Atenea SSH: faltan bibliotecas gráficas:' "$missing" >&2
+ printf '%s\n' 'Instala los paquetes de GTK3 y WebKit2GTK 4.1 de tu distribución. En Ubuntu 24.04: sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0' >&2
+ exit 69
+fi
+if [ "$ldd_status" -ne 0 ]; then
+ printf '%s\n' 'Atenea SSH: no se pudieron comprobar las bibliotecas de la ventana. Comprueba la arquitectura y las dependencias del paquete.' >&2
+ exit 69
+fi
+
+exec "$binary" "$@"
diff --git a/desktop/ssh/scripts/linux_launcher_test.sh b/desktop/ssh/scripts/linux_launcher_test.sh
new file mode 100644
index 00000000..164f5e74
--- /dev/null
+++ b/desktop/ssh/scripts/linux_launcher_test.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+cp scripts/linux_launcher.sh "$tmp/atenea-ssh"
+chmod +x "$tmp/atenea-ssh"
+
+expect_failure() {
+ local expected=$1
+ shift
+ local status=0
+ "$@" >"$tmp/stdout" 2>"$tmp/stderr" || status=$?
+ if [[ $status -eq 0 ]] || ! grep -Fq "$expected" "$tmp/stderr"; then
+ printf 'Unexpected launcher result (status %s):\n' "$status" >&2
+ cat "$tmp/stderr" >&2
+ exit 1
+ fi
+}
+
+expect_failure 'falta el ejecutable' env DISPLAY=:99 "$tmp/atenea-ssh"
+cat >"$tmp/atenea-ssh-bin" <<'EOF'
+#!/bin/sh
+printf 'opened:%s\n' "$1"
+EOF
+chmod +x "$tmp/atenea-ssh-bin"
+expect_failure 'no hay una sesión gráfica' env -u DISPLAY -u WAYLAND_DISPLAY "$tmp/atenea-ssh"
+expect_failure 'no hay una sesión gráfica' env DISPLAY=:99 GDK_BACKEND=wayland WAYLAND_DISPLAY=invalid XDG_RUNTIME_DIR="$tmp" "$tmp/atenea-ssh"
+
+mkdir "$tmp/fakebin"
+cat >"$tmp/fakebin/ldd" <<'EOF'
+#!/bin/sh
+printf '%s\n' 'libwebkit2gtk-4.1.so.0 => not found'
+exit 1
+EOF
+chmod +x "$tmp/fakebin/ldd"
+expect_failure 'libwebkit2gtk-4.1.so.0' env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" "$tmp/atenea-ssh"
+
+cat >"$tmp/fakebin/ldd" <<'EOF'
+#!/bin/sh
+printf '%s\n' 'libwebkit2gtk-4.1.so.0 => /usr/lib/libwebkit2gtk-4.1.so.0'
+EOF
+chmod +x "$tmp/fakebin/ldd"
+actual=$(env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" "$tmp/atenea-ssh" test-argument)
+[[ "$actual" == 'opened:test-argument' ]]
+ln -s ../atenea-ssh "$tmp/fakebin/atenea-ssh-link"
+actual=$(env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" atenea-ssh-link linked-argument)
+[[ "$actual" == 'opened:linked-argument' ]]
+ln -s ../atenea-ssh "$tmp/fakebin/link-0"
+for index in {1..17}; do
+ ln -s "link-$((index - 1))" "$tmp/fakebin/link-$index"
+done
+expect_failure 'demasiados niveles' env DISPLAY=:99 "$tmp/fakebin/link-17"
+printf '%s\n' 'Linux launcher diagnostics passed'
diff --git a/desktop/ssh/scripts/linux_render_smoke.sh b/desktop/ssh/scripts/linux_render_smoke.sh
new file mode 100644
index 00000000..dcec3d77
--- /dev/null
+++ b/desktop/ssh/scripts/linux_render_smoke.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+shell=${ATENEA_SSH_SHELL:-build/bin/atenea-ssh}
+controller=${ATENEA_SSH_CONTROLLER:-build/bin/atenea-ssh-controller}
+capture=${ATENEA_SSH_CAPTURE:-build/ci-artifacts/linux-render-smoke.png}
+log_file=$(mktemp)
+app_pid=''
+
+cleanup() {
+ if [[ -n "$app_pid" ]]; then
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
+ fi
+ "$controller" --stop >/dev/null 2>&1 || true
+ if [[ -s "$log_file" ]]; then cat "$log_file"; fi
+ rm -f "$log_file"
+}
+trap cleanup EXIT
+
+"$shell" >"$log_file" 2>&1 &
+app_pid=$!
+window_id=''
+for _ in {1..60}; do
+ if ! kill -0 "$app_pid" 2>/dev/null; then
+ echo 'Atenea SSH exited before a window appeared' >&2
+ exit 1
+ fi
+ window_id=$(xdotool search --onlyvisible --name '^Atenea SSH$' | head -n 1 || true)
+ if [[ -n "$window_id" ]]; then break; fi
+ sleep 0.5
+done
+if [[ -z "$window_id" ]]; then
+ echo 'Atenea SSH window was not visible within 30 seconds' >&2
+ exit 1
+fi
+
+# Let the packaged WebView finish loading before capturing this synthetic UI.
+sleep 4
+actual_title=$(xdotool getwindowname "$window_id")
+if [[ "$actual_title" != 'Atenea SSH' ]]; then
+ echo 'Atenea SSH native window title changed during render' >&2
+ exit 1
+fi
+mkdir -p "$(dirname "$capture")"
+import -window "$window_id" "$capture"
+test -s "$capture"
+echo "Captured Atenea SSH window: $capture"
diff --git a/desktop/ssh/scripts/linux_wayland_lifecycle.sh b/desktop/ssh/scripts/linux_wayland_lifecycle.sh
new file mode 100644
index 00000000..1b67bf97
--- /dev/null
+++ b/desktop/ssh/scripts/linux_wayland_lifecycle.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# This is a virtual Wayland render and lifecycle test, not a real desktop test.
+shell=build/bin/atenea-ssh
+controller=build/bin/atenea-ssh-controller
+capture=${ATENEA_SSH_CAPTURE:-build/ci-artifacts/linux-wayland.png}
+capture_delay=${ATENEA_SSH_CAPTURE_DELAY:-3}
+test_root=$(mktemp -d)
+weston_pid=''
+first_pid=''
+second_pid=''
+
+cleanup() {
+ for pid in "$first_pid" "$second_pid" "$weston_pid"; do
+ if [[ -n "$pid" ]]; then
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ fi
+ done
+ "$controller" --stop >/dev/null 2>&1 || true
+ if [[ -f "$test_root/failed" ]]; then
+ for name in weston first second screenshooter; do
+ if [[ -s "$test_root/$name.log" ]]; then
+ echo "=== $name log ===" >&2
+ cat "$test_root/$name.log" >&2
+ fi
+ done
+ fi
+ rm -rf "$test_root"
+}
+trap cleanup EXIT
+trap 'touch "$test_root/failed"' ERR
+
+export XDG_RUNTIME_DIR="$test_root/runtime"
+export XDG_CONFIG_HOME="$test_root/config"
+export WAYLAND_DISPLAY=atenea-ssh-test
+export GDK_BACKEND=wayland
+export XDG_SESSION_TYPE=wayland
+unset DISPLAY
+mkdir -m 700 "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$test_root/pictures"
+mkdir -p "$(dirname "$capture")"
+capture_dir=$(cd "$(dirname "$capture")" && pwd)
+capture_name=$(basename "$capture")
+export XDG_PICTURES_DIR="$test_root/pictures"
+
+# Weston debug exposes output capture, safe only on this isolated synthetic CI socket.
+weston --no-config --debug --backend=headless --renderer=pixman --socket="$WAYLAND_DISPLAY" --idle-time=0 >"$test_root/weston.log" 2>&1 &
+weston_pid=$!
+for _ in {1..60}; do
+ if [[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]]; then break; fi
+ kill -0 "$weston_pid"
+ sleep 0.5
+done
+test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"
+
+"$shell" >"$test_root/first.log" 2>&1 &
+first_pid=$!
+"$shell" >"$test_root/second.log" 2>&1 &
+second_pid=$!
+
+controller_root="$XDG_CONFIG_HOME/atenea/ssh-desktop"
+controller_count() {
+ local count=0 proc binary arg root
+ local -a args
+ for proc in /proc/[0-9]*/cmdline; do
+ [[ -r "$proc" ]] || continue
+ mapfile -d '' -t args <"$proc" || true
+ ((${#args[@]} >= 3)) || continue
+ binary=${args[0]}
+ arg=${args[1]}
+ root=${args[2]}
+ if [[ "$binary" == *'/atenea-ssh-controller' && "$arg" == --root && "$root" == "$controller_root" ]]; then
+ ((count += 1))
+ fi
+ done
+ printf '%s\n' "$count"
+}
+
+for _ in {1..60}; do
+ kill -0 "$first_pid"
+ kill -0 "$second_pid"
+ if [[ $(controller_count) == 1 ]]; then break; fi
+ sleep 0.5
+done
+test "$(controller_count)" == 1
+echo 'Wayland: two shell processes share one responsive controller'
+
+sleep "$capture_delay"
+weston-screenshooter >"$test_root/screenshooter.log" 2>&1
+shopt -s nullglob
+screenshots=("$XDG_PICTURES_DIR"/wayland-screenshot-*.png)
+if ((${#screenshots[@]} != 1)); then
+ cat "$test_root/screenshooter.log" >&2
+ echo "Expected one virtual Wayland screenshot, found ${#screenshots[@]}" >&2
+ exit 1
+fi
+mv "${screenshots[0]}" "$capture_dir/$capture_name"
+test -s "$capture_dir/$capture_name"
+echo "Captured virtual Wayland output: $capture"
+
+kill "$first_pid"
+wait "$first_pid" 2>/dev/null || true
+first_pid=''
+kill -0 "$second_pid"
+test "$(controller_count)" == 1
+kill "$second_pid"
+wait "$second_pid" 2>/dev/null || true
+second_pid=''
+test "$(controller_count)" == 1
+
+"$controller" --stop
+for _ in {1..20}; do
+ if [[ $(controller_count) == 0 ]]; then break; fi
+ sleep 0.2
+done
+test "$(controller_count)" == 0
+echo 'Wayland: shell close leaves controller running; explicit stop ends it'
diff --git a/desktop/ssh/scripts/restricted_wails.py b/desktop/ssh/scripts/restricted_wails.py
new file mode 100644
index 00000000..ac8255dc
--- /dev/null
+++ b/desktop/ssh/scripts/restricted_wails.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""Build and test pinned Wails with Atenea's host-side dispatcher guard.
+
+The upstream module is copied to a temporary Go workspace and patched only
+after exact source-hash checks. The application will not compile without the
+guard marker added to that verified source copy.
+"""
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+
+
+ROOT = Path(__file__).resolve().parents[1]
+WAILS = "github.com/wailsapp/wails/v2"
+WEBVIEW2 = "github.com/wailsapp/go-webview2"
+WEBVIEW2_CHROMIUM_HASH = "6013bea6dc614888de282a37febebdfb31984377b317b3911671b20209b8b671"
+SOURCE_HASHES = {
+ "internal/frontend/dispatcher/dispatcher.go": "baa6bc120411c07323e66476bb14a9872088a970449a600a765890012beb3133",
+ "internal/frontend/desktop/darwin/frontend.go": "96b4e064ea8178a0ae26e65eab5c92c4200eca3f0f241c2035f88de6eaead35e",
+ "internal/frontend/desktop/darwin/WailsContext.m": "ffb03f12a11f4af78b3ea2fac0ef5b2b931bc5de0b0e835ed75554fd7ad91e72",
+ "internal/frontend/desktop/linux/frontend.go": "2610e740979055e636a30d05670126aad3fa6b77071c3f9af20e2fb6c47c89f5",
+ "internal/frontend/desktop/linux/window.c": "76529ab2c6eb8a3b195823f934ca14adac937b300ad0420cd6e3e6e6630b5643",
+ "internal/frontend/desktop/windows/frontend.go": "9598c7f21779e2332db788a9e09f3b4856d563ab5fc2c93bf7035743b9d7befa",
+ "pkg/options/options.go": "9ec72bb753c04f7bf1f5d09ab973db41791028df3f13051d5fac5c19143a6d2e",
+}
+MARKER = "AteneaSSHBridgeGuard"
+GUARD_CALL = "\tif !ateneaSSHAllowedMessage(message) {\n\t\treturn \"\", nil // Drop untrusted messages without logging their contents.\n\t}\n"
+
+
+def run(*args: str, env: dict[str, str] | None = None, cwd: Path = ROOT) -> None:
+ subprocess.run(args, cwd=cwd, env=env, check=True)
+
+
+def prepare_workspace(tmp: Path) -> Path:
+ subprocess.run(["go", "mod", "verify"], cwd=ROOT, check=True, stdout=subprocess.DEVNULL)
+ version = subprocess.check_output(
+ ["go", "list", "-m", "-f", "{{.Version}}", WAILS], cwd=ROOT, text=True
+ ).strip()
+ if version != "v2.15.0":
+ raise RuntimeError(f"expected Wails v2.15.0, found {version or 'local replacement'}")
+ module_dir = Path(
+ subprocess.check_output(
+ ["go", "list", "-m", "-f", "{{.Dir}}", WAILS], cwd=ROOT, text=True
+ ).strip()
+ )
+ webview_version = subprocess.check_output(
+ ["go", "list", "-m", "-f", "{{.Version}}", WEBVIEW2], cwd=ROOT, text=True
+ ).strip()
+ if webview_version != "v1.0.22":
+ raise RuntimeError(f"expected go-webview2 v1.0.22, found {webview_version or 'local replacement'}")
+ webview_dir = Path(
+ subprocess.check_output(
+ ["go", "list", "-m", "-f", "{{.Dir}}", WEBVIEW2], cwd=ROOT, text=True
+ ).strip()
+ )
+ chromium_source = webview_dir / "pkg/edge/chromium.go"
+ if hashlib.sha256(chromium_source.read_bytes()).hexdigest() != WEBVIEW2_CHROMIUM_HASH:
+ raise RuntimeError("go-webview2 Chromium source changed; audit before updating guard")
+ for relative, expected_hash in SOURCE_HASHES.items():
+ original = module_dir / relative
+ source = original.read_bytes()
+ if hashlib.sha256(source).hexdigest() != expected_hash:
+ raise RuntimeError(f"Wails source changed: {relative}; audit before updating guard")
+ patched = tmp / "wails"
+ shutil.copytree(module_dir, patched)
+ patched_webview = tmp / "go-webview2"
+ shutil.copytree(webview_dir, patched_webview)
+ chromium = patched_webview / "pkg/edge/chromium.go"
+ content = chromium.read_text(encoding="utf-8")
+ webview_anchors = {
+ "navigationCompleted *ICoreWebView2NavigationCompletedEventHandler\n": "navigationCompleted *ICoreWebView2NavigationCompletedEventHandler\n\tnavigationStarting *ateneaNavigationHandler\n\tnewWindowRequested *ateneaNewWindowHandler\n",
+ "e.navigationCompleted = newICoreWebView2NavigationCompletedEventHandler(e)\n": "e.navigationCompleted = newICoreWebView2NavigationCompletedEventHandler(e)\n\te.navigationStarting = &ateneaNavigationHandler{vtbl: &ateneaNavigationVtable, owner: e}\n\te.newWindowRequested = &ateneaNewWindowHandler{vtbl: &ateneaNewWindowVtable, owner: e}\n",
+ }
+ for anchor, replacement in webview_anchors.items():
+ if content.count(anchor) != 1:
+ raise RuntimeError("go-webview2 Chromium navigation anchor changed")
+ content = content.replace(anchor, replacement)
+ registration = "err = e.webview.AddNavigationCompleted(e.navigationCompleted, &token)\n\tif err != nil {\n\t\te.errorCallback(err)\n\t}\n"
+ if content.count(registration) != 1:
+ raise RuntimeError("go-webview2 Chromium registration anchor changed")
+ content = content.replace(registration, registration + "\te.ateneaRegisterNavigation()\n")
+ chromium.parent.chmod(0o700)
+ chromium.chmod(0o600)
+ chromium.write_text(content, encoding="utf-8")
+ subprocess.run(["gofmt", "-w", str(chromium)], check=True)
+ for filename in ("navigation_windows.go.txt", "navigation_windows_test.go.txt"):
+ target = patched_webview / "pkg/edge" / filename.removesuffix(".txt").replace("navigation_windows", "atenea_navigation")
+ target.write_text((ROOT / "bridge" / filename).read_text(encoding="utf-8"), encoding="utf-8")
+ subprocess.run(["gofmt", "-w", str(target)], check=True)
+ for relative in SOURCE_HASHES:
+ target = patched / relative
+ content = target.read_text(encoding="utf-8")
+ if relative.endswith("dispatcher.go"):
+ import_anchor = 'import (\n\t"context"\n'
+ function_anchor = 'func (d *Dispatcher) ProcessMessage(message string, sender frontend.Frontend) (_ string, err error) {\n'
+ if content.count(import_anchor) != 1 or content.count(function_anchor) != 1:
+ raise RuntimeError("Wails dispatcher anchors changed")
+ content = content.replace(import_anchor, import_anchor + '\t"encoding/json"\n')
+ content = content.replace(function_anchor, function_anchor + GUARD_CALL)
+ log_anchor = 'd.log.Error("process message error: %s -> %s", message, err)'
+ if content.count(log_anchor) != 1:
+ raise RuntimeError("Wails dispatcher error logger changed")
+ content = content.replace(log_anchor, 'd.log.Error("process message error")')
+ content += (ROOT / "bridge/guard.go.txt").read_text(encoding="utf-8")
+ elif relative.endswith("frontend.go"):
+ if "darwin" in relative or "linux" in relative:
+ anchor = "func (f *Frontend) processMessage(message string) {\n"
+ else:
+ anchor = "func (f *Frontend) processMessage(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {\n"
+ if content.count(anchor) != 1:
+ raise RuntimeError(f"Wails ingress anchor changed: {relative}")
+ content = content.replace(anchor, anchor + "\tif !ateneaSSHIngressMessage(message) { return }\n")
+ content += (ROOT / "bridge/ingress.go.txt").read_text(encoding="utf-8")
+ if "windows" in relative:
+ extra_anchor = "func (f *Frontend) processMessageWithAdditionalObjects(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {\n"
+ if content.count(extra_anchor) != 1:
+ raise RuntimeError("Wails Windows additional-objects anchor changed")
+ content = content.replace(extra_anchor, extra_anchor + "\tif !ateneaSSHIngressMessage(message) || message[0] != 'C' { return }\n")
+ permission_anchor = "chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateAllow)"
+ if content.count(permission_anchor) != 1:
+ raise RuntimeError("Wails Windows WebView2 permission anchor changed")
+ content = content.replace(permission_anchor, "chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateDeny)")
+ elif relative.endswith("WailsContext.m"):
+ anchor = "- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {\n"
+ if content.count(anchor) != 1:
+ raise RuntimeError("Wails macOS navigation anchor changed")
+ content = content.replace(anchor, (ROOT / "bridge/navigation_darwin.m.txt").read_text(encoding="utf-8") + anchor)
+ elif relative.endswith("window.c"):
+ function_anchor = "static void webviewLoadChanged(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer data)\n"
+ signal_anchor = ' g_signal_connect(G_OBJECT(webview), "load-changed", G_CALLBACK(webviewLoadChanged), NULL);\n'
+ if content.count(function_anchor) != 1 or content.count(signal_anchor) != 1:
+ raise RuntimeError("Wails Linux navigation anchors changed")
+ content = content.replace(function_anchor, (ROOT / "bridge/navigation_linux.c.txt").read_text(encoding="utf-8") + function_anchor)
+ content = content.replace(signal_anchor, signal_anchor + ' g_signal_connect(G_OBJECT(webview), "decide-policy", G_CALLBACK(ateneaNavigationPolicy), NULL);\n')
+ else:
+ content += f'\n// {MARKER} proves this app was built with the reviewed Wails overlay.\nconst {MARKER} = "wails-v2.15.0-guard-v1"\n'
+ target.parent.chmod(0o700)
+ target.chmod(0o600)
+ target.write_text(content, encoding="utf-8")
+ if target.suffix == ".go":
+ subprocess.run(["gofmt", "-w", str(target)], check=True)
+ dispatcher_dir = patched / "internal/frontend/dispatcher"
+ dispatcher_dir.chmod(0o700)
+ (dispatcher_dir / "atenea_guard_test.go").write_text(
+ (ROOT / "bridge/guard_test.go.txt").read_text(encoding="utf-8"), encoding="utf-8"
+ )
+ subprocess.run(["gofmt", "-w", str(dispatcher_dir / "atenea_guard_test.go")], check=True)
+ for platform in ("darwin", "linux", "windows"):
+ ingress_test = patched / f"internal/frontend/desktop/{platform}/atenea_ingress_test.go"
+ ingress_test.write_text((ROOT / "bridge/ingress_test.go.txt").read_text(encoding="utf-8").replace("PLATFORM", platform, 1), encoding="utf-8")
+ subprocess.run(["gofmt", "-w", str(ingress_test)], check=True)
+ probe = tmp / "ingress-probe"
+ probe.mkdir()
+ (probe / "go.mod").write_text("module atenea-ssh-ingress-probe\n\ngo 1.25.0\n", encoding="utf-8")
+ (probe / "ingress.go").write_text("package ingressprobe\n" + (ROOT / "bridge/ingress.go.txt").read_text(encoding="utf-8"), encoding="utf-8")
+ (probe / "ingress_test.go").write_text(
+ (ROOT / "bridge/ingress_test.go.txt").read_text(encoding="utf-8").replace("PLATFORM", "ingressprobe", 1), encoding="utf-8"
+ )
+ workspace = tmp / "go.work"
+ workspace.write_text(
+ "go 1.26.7\nuse (\n"
+ + f" {json.dumps(str(ROOT.parents[1]))}\n"
+ + f" {json.dumps(str(ROOT))}\n"
+ + f" {json.dumps(str(patched))}\n"
+ + f" {json.dumps(str(patched_webview))}\n"
+ + ")\n", encoding="utf-8"
+ )
+ return workspace
+
+
+def main() -> None:
+ if len(sys.argv) != 2 or sys.argv[1] not in {"test", "build", "probe-build", "navigation-probe-build"}:
+ raise SystemExit("usage: restricted_wails.py test|build|probe-build|navigation-probe-build")
+ with tempfile.TemporaryDirectory(prefix="atenea-wails-") as directory:
+ workspace = prepare_workspace(Path(directory))
+ env = dict(os.environ)
+ env["GOWORK"] = str(workspace)
+ if sys.argv[1] == "test":
+ tags = ("-tags", "webkit2_41") if sys.platform.startswith("linux") else ()
+ run("go", "test", "-count=1", WAILS + "/internal/frontend/dispatcher", env=env)
+ if sys.platform == "win32":
+ # The dependency's own graphical tests need an interactive COM
+ # session that CI runners do not provide. Run our URL policy
+ # test, then compile the complete guarded shell below.
+ run("go", "test", "-count=1", "-run", "^TestAteneaAllowedNavigation$", WEBVIEW2 + "/pkg/edge", env=env)
+ probe_env = dict(env)
+ probe_env["GOWORK"] = "off"
+ run("go", "test", "-count=1", "./...", cwd=Path(directory) / "ingress-probe", env=probe_env)
+ run("go", "vet", *tags, "./...", env=env)
+ run("go", "test", *tags, "./...", env=env)
+ else:
+ if sys.argv[1] == "probe-build":
+ env["VITE_ATENEA_BRIDGE_PROBE"] = "1"
+ else:
+ env.pop("VITE_ATENEA_BRIDGE_PROBE", None)
+ if sys.argv[1] == "navigation-probe-build":
+ env["VITE_ATENEA_NAVIGATION_PROBE"] = "1"
+ else:
+ env.pop("VITE_ATENEA_NAVIGATION_PROBE", None)
+ platform = env.pop("ATENEA_WAILS_PLATFORM", "")
+ if platform and platform not in {"darwin/arm64", "windows/amd64", "windows/arm64", "linux/amd64"}:
+ raise RuntimeError(f"unsupported Wails target: {platform}")
+ build_args = ["go", "run", WAILS + "/cmd/wails", "build", "-clean"]
+ if platform:
+ build_args.extend(["-platform", platform])
+ target_linux = platform.startswith("linux/") if platform else sys.platform.startswith("linux")
+ build_args.extend(["-tags", "atenea_ssh_restricted" + (",webkit2_41" if target_linux else "")])
+ run(
+ *build_args,
+ env=env,
+ )
+ assets = list((ROOT / "frontend/dist/assets").glob("*.js"))
+ has_probe = any(b"Runtime no disponible" in asset.read_bytes() for asset in assets)
+ if not assets or has_probe != (sys.argv[1] == "probe-build"):
+ raise RuntimeError("frontend probe mode does not match requested build")
+ has_title_probe = any(b"WTAtenea SSH probe escaped" in asset.read_bytes() for asset in assets)
+ if has_title_probe != (sys.argv[1] == "probe-build"):
+ raise RuntimeError("native window-title probe mode does not match requested build")
+ has_navigation_probe = any(b"atenea-navigation-probe" in asset.read_bytes() for asset in assets)
+ if has_navigation_probe != (sys.argv[1] == "navigation-probe-build"):
+ raise RuntimeError("frontend navigation probe mode does not match requested build")
+ if target_linux:
+ native = ROOT / "build/bin/atenea-ssh"
+ if not native.is_file():
+ raise RuntimeError("Linux Wails executable missing after build")
+ native.rename(native.with_name("atenea-ssh-bin"))
+ shutil.copy2(ROOT / "scripts/linux_launcher.sh", native)
+ native.chmod(0o755)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/desktop/ssh/wails.json b/desktop/ssh/wails.json
new file mode 100644
index 00000000..9010c3d7
--- /dev/null
+++ b/desktop/ssh/wails.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "atenea-ssh",
+ "outputfilename": "atenea-ssh",
+ "frontend:install": "bun install --frozen-lockfile",
+ "frontend:build": "bun run build",
+ "frontend:dev:watcher": "bun run dev",
+ "frontend:dev:serverUrl": "auto"
+}
diff --git a/go.mod b/go.mod
index 28543d14..927bd917 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.25.13
require (
github.com/BurntSushi/toml v1.6.0
+ github.com/Microsoft/go-winio v0.6.2
github.com/duckdb/duckdb-go/v2 v2.5.6
github.com/google/uuid v1.6.0
github.com/mattn/go-isatty v0.0.24
diff --git a/go.sum b/go.sum
index 67d2a7c6..47e8505f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
diff --git a/internal/sshcontrol/controller/controller.go b/internal/sshcontrol/controller/controller.go
new file mode 100644
index 00000000..eecfa063
--- /dev/null
+++ b/internal/sshcontrol/controller/controller.go
@@ -0,0 +1,218 @@
+// Package controller implements the fixture-only local desktop service.
+// Native peer checks belong to localipc; no message here authorizes SSH work.
+package controller
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/handshake"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/localipc"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/wire"
+)
+
+const requestTimeout = 5 * time.Second
+
+// ErrProtocol reports a disallowed fixture operation or malformed response.
+var ErrProtocol = errors.New("ssh controller: invalid fixture request")
+
+// Status contains only controller lifecycle data. It contains no host or secret.
+type Status struct {
+ State string `json:"state"`
+ Protocol string `json:"protocol"`
+}
+
+type request struct {
+ Operation string `json:"operation"`
+}
+
+// Root selects the dedicated state directory for the current OS account.
+func Root() (string, error) {
+ config, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(config, "atenea", "ssh-desktop"), nil
+}
+
+// InstallationID creates a stable, user-local identifier. The native transport
+// authenticates the peer; this identifier is only a compatibility check.
+func InstallationID(root string) (string, error) {
+ if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root {
+ return "", localipc.ErrPrivateRoot
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return "", err
+ }
+ info, err := os.Lstat(root)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", localipc.ErrPrivateRoot
+ }
+ if err := prepareRoot(root); err != nil {
+ return "", err
+ }
+ path := filepath.Join(root, "installation.id")
+ if data, err := os.ReadFile(path); err == nil {
+ id := string(data)
+ parsed, parseErr := uuid.Parse(id)
+ if parseErr != nil || parsed == uuid.Nil || parsed.String() != id {
+ return "", ErrProtocol
+ }
+ return id, nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return "", err
+ }
+ id, err := uuid.NewRandomFromReader(rand.Reader)
+ if err != nil {
+ return "", err
+ }
+ // Write a private temporary file before publishing the final path. Linking
+ // within the same directory is atomic and cannot replace another winner.
+ f, err := os.CreateTemp(root, ".installation-*.tmp")
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = os.Remove(f.Name()) }()
+ if _, err = f.WriteString(id.String()); err != nil {
+ _ = f.Close()
+ return "", err
+ }
+ if err = f.Sync(); err != nil {
+ _ = f.Close()
+ return "", err
+ }
+ if err = f.Close(); err != nil {
+ return "", err
+ }
+ if err = os.Link(f.Name(), path); errors.Is(err, os.ErrExist) {
+ return InstallationID(root)
+ } else if err != nil {
+ return "", err
+ }
+ return id.String(), nil
+}
+
+// Serve owns the authenticated endpoint until cancellation or an explicit stop.
+// Closing the graphical window does not close this listener.
+func Serve(ctx context.Context, root, installationID string) error {
+ hello, err := handshake.New(handshake.Controller, installationID)
+ if err != nil {
+ return err
+ }
+ listener, err := localipc.Listen(root)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = listener.Close() }()
+ serveCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ stop := context.AfterFunc(serveCtx, func() { _ = listener.Close() })
+ defer stop()
+ var workers sync.WaitGroup
+ defer workers.Wait()
+ slots := make(chan struct{}, 8)
+ for {
+ conn, acceptErr := listener.Accept()
+ if acceptErr != nil {
+ if serveCtx.Err() != nil {
+ return nil
+ }
+ return acceptErr
+ }
+ select {
+ case slots <- struct{}{}:
+ workers.Add(1)
+ go func() { defer workers.Done(); defer func() { <-slots }(); serveConn(serveCtx, conn, hello, cancel) }()
+ default:
+ _ = conn.Close()
+ }
+ }
+}
+
+func serveConn(ctx context.Context, conn net.Conn, hello handshake.Hello, stop context.CancelFunc) {
+ defer func() { _ = conn.Close() }()
+ negotiation, err := handshake.Exchange(ctx, conn, hello)
+ if err != nil || negotiation.Peer.Component != handshake.Desktop {
+ return
+ }
+ _ = conn.SetDeadline(time.Now().Add(requestTimeout))
+ payload, err := wire.ReadFrame(conn)
+ if err != nil {
+ return
+ }
+ req, err := decodeRequest(payload)
+ if err != nil {
+ return
+ }
+ switch req.Operation {
+ case "status":
+ response, _ := json.Marshal(Status{State: "running", Protocol: "1.0"})
+ _ = wire.WriteFrame(conn, response)
+ case "stop":
+ response, _ := json.Marshal(Status{State: "stopping", Protocol: "1.0"})
+ if wire.WriteFrame(conn, response) == nil {
+ stop()
+ }
+ }
+}
+
+func decodeRequest(payload []byte) (request, error) {
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(payload, &fields) != nil || len(fields) != 1 || fields["operation"] == nil {
+ return request{}, ErrProtocol
+ }
+ var operation string
+ if json.Unmarshal(fields["operation"], &operation) != nil || (operation != "status" && operation != "stop") {
+ return request{}, ErrProtocol
+ }
+ return request{Operation: operation}, nil
+}
+
+// Call makes one bounded fixture request over a freshly authenticated channel.
+func Call(ctx context.Context, root, installationID, operation string) (Status, error) {
+ if operation != "status" && operation != "stop" {
+ return Status{}, ErrProtocol
+ }
+ hello, err := handshake.New(handshake.Desktop, installationID)
+ if err != nil {
+ return Status{}, err
+ }
+ conn, err := localipc.Dial(root, requestTimeout)
+ if err != nil {
+ return Status{}, err
+ }
+ defer func() { _ = conn.Close() }()
+ if _, err := handshake.Exchange(ctx, conn, hello); err != nil {
+ return Status{}, err
+ }
+ _ = conn.SetDeadline(time.Now().Add(requestTimeout))
+ payload, _ := json.Marshal(request{Operation: operation})
+ if err := wire.WriteFrame(conn, payload); err != nil {
+ return Status{}, err
+ }
+ response, err := wire.ReadFrame(conn)
+ if err != nil {
+ return Status{}, err
+ }
+ var status Status
+ if err := json.Unmarshal(response, &status); err != nil {
+ return Status{}, ErrProtocol
+ }
+ if status.Protocol != "1.0" || (status.State != "running" && status.State != "stopping") {
+ return Status{}, fmt.Errorf("%w: invalid status", ErrProtocol)
+ }
+ return status, nil
+}
diff --git a/internal/sshcontrol/controller/controller_process_test.go b/internal/sshcontrol/controller/controller_process_test.go
new file mode 100644
index 00000000..8112a35b
--- /dev/null
+++ b/internal/sshcontrol/controller/controller_process_test.go
@@ -0,0 +1,211 @@
+package controller
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/handshake"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/localipc"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/wire"
+)
+
+const childRootEnv = "ATENEA_SSH_CONTROLLER_TEST_ROOT"
+const idRootEnv = "ATENEA_SSH_INSTALLATION_ID_TEST_ROOT"
+const idOutputEnv = "ATENEA_SSH_INSTALLATION_ID_TEST_OUTPUT"
+
+func TestInstallationIDAcrossProcesses(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "controller")
+ const callers = 8
+ var commands [callers]*exec.Cmd
+ var outputs [callers]bytes.Buffer
+ var paths [callers]string
+ for i := range commands {
+ paths[i] = filepath.Join(t.TempDir(), "result")
+ cmd := exec.Command(os.Args[0], "-test.run=^TestInstallationIDSubprocess$", "-test.timeout=20s")
+ cmd.Env = append(os.Environ(), idRootEnv+"="+root, idOutputEnv+"="+paths[i])
+ cmd.Stdout, cmd.Stderr = &outputs[i], &outputs[i]
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ commands[i] = cmd
+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
+ }
+ want := ""
+ for i, cmd := range commands {
+ if err := cmd.Wait(); err != nil {
+ t.Fatalf("installation ID child %d: %v: %s", i, err, outputs[i].String())
+ }
+ data, err := os.ReadFile(paths[i])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want == "" {
+ want = string(data)
+ } else if string(data) != want {
+ t.Fatalf("separate processes created different installation IDs")
+ }
+ }
+ if current, err := InstallationID(root); err != nil || current != want {
+ t.Fatalf("published installation ID differs from child processes: %v", err)
+ }
+}
+
+func TestInstallationIDSubprocess(t *testing.T) {
+ root, output := os.Getenv(idRootEnv), os.Getenv(idOutputEnv)
+ if root == "" || output == "" {
+ t.Skip("child-only")
+ }
+ id, err := InstallationID(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(output, []byte(id), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRestartAfterKilledController(t *testing.T) {
+ base := t.TempDir()
+ if runtime.GOOS != "windows" {
+ shortTemp := "/tmp"
+ if runtime.GOOS == "darwin" {
+ shortTemp = "/private/tmp"
+ }
+ var err error
+ base, err = os.MkdirTemp(shortTemp, "a151-restart-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(base) })
+ }
+ root := filepath.Join(base, "controller")
+ id, err := InstallationID(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ start := func() (*exec.Cmd, *bytes.Buffer) {
+ t.Helper()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestControllerSubprocess$", "-test.timeout=30s")
+ cmd.Env = append(os.Environ(), childRootEnv+"="+root)
+ var output bytes.Buffer
+ cmd.Stdout = &output
+ cmd.Stderr = &output
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
+ return cmd, &output
+ }
+ awaitRunning := func(cmd *exec.Cmd, output *bytes.Buffer) {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ status, err := Call(context.Background(), root, id, "status")
+ if err == nil && status.State == "running" {
+ return
+ }
+ if time.Now().After(deadline) {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ t.Fatalf("controller did not become ready: %v, output: %s", err, output.String())
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ }
+
+ first, firstOutput := start()
+ awaitRunning(first, firstOutput)
+ otherID := uuid.NewString()
+ if otherID == id {
+ t.Fatal("fixture installation IDs collided")
+ }
+ if _, err := Call(context.Background(), root, otherID, "stop"); err == nil {
+ t.Fatalf("different installation could stop controller: %v", err)
+ }
+ conn, err := localipc.Dial(root, time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := conn.SetDeadline(time.Now().Add(time.Second)); err != nil {
+ _ = conn.Close()
+ t.Fatal(err)
+ }
+ incompatible, err := handshake.New(handshake.Desktop, id)
+ if err != nil {
+ _ = conn.Close()
+ t.Fatal(err)
+ }
+ incompatible.ProtocolMajor++
+ payload, err := json.Marshal(incompatible)
+ if err != nil {
+ _ = conn.Close()
+ t.Fatal(err)
+ }
+ if err := wire.WriteFrame(conn, payload); err != nil {
+ _ = conn.Close()
+ t.Fatal(err)
+ }
+ if _, err := wire.ReadFrame(conn); err == nil {
+ _ = conn.Close()
+ t.Fatal("incompatible protocol received a response")
+ }
+ _ = conn.Close()
+ if status, err := Call(context.Background(), root, id, "status"); err != nil || status.State != "running" {
+ t.Fatalf("rejected peers disrupted controller: %+v, %v", status, err)
+ }
+ contenderCtx, cancelContender := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelContender()
+ contender := exec.CommandContext(contenderCtx, os.Args[0], "-test.run=^TestControllerSubprocess$", "-test.timeout=10s")
+ contender.Env = append(os.Environ(), childRootEnv+"="+root)
+ if output, err := contender.CombinedOutput(); err == nil || contenderCtx.Err() != nil {
+ t.Fatalf("second controller acquired a live endpoint or hung: %v, output: %s", err, output)
+ }
+ if status, err := Call(context.Background(), root, id, "status"); err != nil || status.State != "running" {
+ t.Fatalf("first controller lost its endpoint: %+v, %v", status, err)
+ }
+ if err := first.Process.Kill(); err != nil {
+ t.Fatal(err)
+ }
+ if err := first.Wait(); err == nil {
+ t.Fatal("killed controller exited successfully")
+ }
+ if runtime.GOOS != "windows" {
+ if _, err := os.Lstat(localipc.Endpoint(root)); err != nil {
+ t.Fatalf("killed controller left no socket to recover: %v", err)
+ }
+ }
+
+ second, secondOutput := start()
+ awaitRunning(second, secondOutput)
+ if _, err := Call(context.Background(), root, id, "stop"); err != nil {
+ t.Fatal(err)
+ }
+ if err := second.Wait(); err != nil {
+ t.Fatalf("restarted controller did not stop cleanly: %v, output: %s", err, secondOutput.String())
+ }
+}
+
+// TestControllerSubprocess is only entered by the parent test above.
+func TestControllerSubprocess(t *testing.T) {
+ root := os.Getenv(childRootEnv)
+ if root == "" {
+ t.Skip("child-only")
+ }
+ id, err := InstallationID(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Serve(context.Background(), root, id); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/sshcontrol/controller/controller_test.go b/internal/sshcontrol/controller/controller_test.go
new file mode 100644
index 00000000..36ac518c
--- /dev/null
+++ b/internal/sshcontrol/controller/controller_test.go
@@ -0,0 +1,130 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestFixtureControllerLifecycle(t *testing.T) {
+ var base string
+ if runtime.GOOS == "windows" {
+ base = t.TempDir()
+ } else {
+ shortTemp := "/tmp"
+ if runtime.GOOS == "darwin" {
+ shortTemp = "/private/tmp"
+ }
+ var err error
+ base, err = os.MkdirTemp(shortTemp, "a151-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(base) })
+ }
+ root := filepath.Join(base, "controller")
+ id, err := InstallationID(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, err := InstallationID(root)
+ if err != nil || again != id {
+ t.Fatalf("unstable id: %q %v", again, err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- Serve(ctx, root, id) }()
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ select {
+ case err := <-done:
+ t.Fatalf("controller exited before ready: %v", err)
+ default:
+ }
+ status, callErr := Call(context.Background(), root, id, "status")
+ if callErr == nil {
+ if status.State != "running" {
+ t.Fatalf("status: %+v", status)
+ }
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal(callErr)
+ }
+ runtime.Gosched()
+ }
+ if _, err := Call(context.Background(), root, id, "exec"); !errors.Is(err, ErrProtocol) {
+ t.Fatalf("unexpected operation: %v", err)
+ }
+ if _, err := Call(context.Background(), root, id, "stop"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("stop did not terminate")
+ }
+}
+
+func TestInstallationIDConcurrentCreation(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "controller")
+ const callers = 64
+ start := make(chan struct{})
+ results := make(chan struct {
+ id string
+ err error
+ }, callers)
+ var workers sync.WaitGroup
+ for range callers {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ <-start
+ id, err := InstallationID(root)
+ results <- struct {
+ id string
+ err error
+ }{id, err}
+ }()
+ }
+ close(start)
+ workers.Wait()
+ close(results)
+ want := ""
+ for result := range results {
+ if result.err != nil {
+ t.Fatalf("concurrent installation ID: %v", result.err)
+ }
+ if want == "" {
+ want = result.id
+ } else if result.id != want {
+ t.Fatalf("two installation IDs: %q and %q", want, result.id)
+ }
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil || len(entries) != 1 || entries[0].Name() != "installation.id" {
+ t.Fatalf("unexpected installation files: %v, %v", entries, err)
+ }
+}
+
+func TestRequestIsExactAndLimited(t *testing.T) {
+ for _, raw := range []string{
+ `{"Operation":"stop"}`, `{"operation":"exec"}`, `{"operation":"stop","extra":true}`, `{"operation":null}`,
+ } {
+ if _, err := decodeRequest([]byte(raw)); !errors.Is(err, ErrProtocol) {
+ t.Fatalf("accepted %s: %v", raw, err)
+ }
+ }
+ if req, err := decodeRequest([]byte(`{"operation":"status"}`)); err != nil || req.Operation != "status" {
+ t.Fatalf("valid status: %+v %v", req, err)
+ }
+}
diff --git a/internal/sshcontrol/controller/root_unix.go b/internal/sshcontrol/controller/root_unix.go
new file mode 100644
index 00000000..5dfe3ee1
--- /dev/null
+++ b/internal/sshcontrol/controller/root_unix.go
@@ -0,0 +1,25 @@
+//go:build darwin || linux
+
+package controller
+
+import (
+ "os"
+ "syscall"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/localipc"
+)
+
+func prepareRoot(root string) error {
+ info, err := os.Lstat(root)
+ if err != nil {
+ return err
+ }
+ owner, ok := info.Sys().(*syscall.Stat_t)
+ if !ok || owner.Uid != uint32(os.Geteuid()) {
+ return localipc.ErrPrivateRoot
+ }
+ if err := os.Chmod(root, 0o700); err != nil {
+ return err
+ }
+ return localipc.CheckRoot(root)
+}
diff --git a/internal/sshcontrol/controller/root_windows.go b/internal/sshcontrol/controller/root_windows.go
new file mode 100644
index 00000000..609c7b86
--- /dev/null
+++ b/internal/sshcontrol/controller/root_windows.go
@@ -0,0 +1,7 @@
+//go:build windows
+
+package controller
+
+import "github.com/Tutitoos/atenea/internal/sshcontrol/localipc"
+
+func prepareRoot(root string) error { return localipc.CheckRoot(root) }
diff --git a/internal/sshcontrol/handshake/handshake.go b/internal/sshcontrol/handshake/handshake.go
new file mode 100644
index 00000000..3077a1d3
--- /dev/null
+++ b/internal/sshcontrol/handshake/handshake.go
@@ -0,0 +1,205 @@
+// Package handshake negotiates the desktop/controller protocol after the native
+// transport has authenticated both peers. The self-reported identifiers below
+// provide compatibility and routing checks, never OS-user authentication.
+package handshake
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/wire"
+)
+
+const (
+ // Major is the current incompatible protocol version.
+ Major uint16 = 1
+ // Minor is the highest backward-compatible version implemented here.
+ Minor uint16 = 0
+ // Desktop identifies the graphical process, which initiates the exchange.
+ Desktop = "desktop"
+ // Controller identifies the independent process, which answers the exchange.
+ Controller = "controller"
+ // Timeout bounds handshakes even when the caller has no earlier deadline.
+ Timeout = 5 * time.Second
+)
+
+var (
+ // ErrInvalid reports an invalid closed-schema handshake without input text.
+ ErrInvalid = errors.New("ssh handshake: invalid message")
+ // ErrVersion reports a different, unsupported major protocol version.
+ ErrVersion = errors.New("ssh handshake: incompatible protocol")
+ // ErrPeer reports an unexpected role or installation identity.
+ ErrPeer = errors.New("ssh handshake: unexpected peer")
+ // ErrTransport reports an unsuccessful exchange without transport payloads.
+ ErrTransport = errors.New("ssh handshake: transport unavailable")
+)
+
+// Hello is a process description, not a credential. InstallationID is provided
+// by the installed local package/controller, not accepted from a launch URL.
+// SessionInstanceID identifies this process lifetime and changes on restart.
+// No per-connection execution authorization is carried forward by this message.
+type Hello struct {
+ ProtocolMajor uint16 `json:"protocol_major"`
+ ProtocolMinor uint16 `json:"protocol_minor"`
+ Component string `json:"component"`
+ InstallationID string `json:"installation_id"`
+ SessionInstanceID string `json:"session_instance_id"`
+}
+
+// Result is connection-local negotiation metadata. It grants no permission to
+// dispatch work, use credentials, open a different user's window or read history.
+type Result struct {
+ Peer Hello
+ ProtocolMinor uint16
+}
+
+// New constructs a hello with a fresh process-instance identity. Keep this value
+// for the process lifetime; the OS transport must authenticate each connection.
+func New(component, installationID string) (Hello, error) {
+ instance, err := uuid.NewRandom()
+ if err != nil {
+ return Hello{}, ErrInvalid
+ }
+ hello := Hello{Major, Minor, component, installationID, instance.String()}
+ if err := hello.validate(); err != nil {
+ return Hello{}, err
+ }
+ return hello, nil
+}
+
+func canonicalID(s string) bool {
+ id, err := uuid.Parse(s)
+ return err == nil && id != uuid.Nil && id.String() == s
+}
+
+func (h Hello) validate() error {
+ if h.ProtocolMajor == 0 || (h.Component != Desktop && h.Component != Controller) || !canonicalID(h.InstallationID) || !canonicalID(h.SessionInstanceID) {
+ return ErrInvalid
+ }
+ return nil
+}
+
+// Decode enforces required, exact-case field names before typed decoding. This
+// prevents encoding/json's case-insensitive field matching from accepting two
+// differently spelled keys that overwrite the same field. Duplicate JSON keys
+// and invalid UTF-8 are rejected by the framing layer, including escaped keys.
+func Decode(payload []byte) (Hello, error) {
+ if err := wire.Validate(payload); err != nil {
+ return Hello{}, ErrInvalid
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(payload, &fields); err != nil {
+ return Hello{}, ErrInvalid
+ }
+ names := [...]string{"protocol_major", "protocol_minor", "component", "installation_id", "session_instance_id"}
+ if len(fields) != len(names) {
+ return Hello{}, ErrInvalid
+ }
+ for _, name := range names {
+ value, ok := fields[name]
+ if !ok || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
+ return Hello{}, ErrInvalid
+ }
+ }
+ var hello Hello
+ if err := json.Unmarshal(payload, &hello); err != nil {
+ return Hello{}, ErrInvalid
+ }
+ if err := hello.validate(); err != nil {
+ return Hello{}, err
+ }
+ return hello, nil
+}
+
+// Negotiate checks role, installed identity and version against trusted local
+// configuration. A newer minor selects the common supported version; a newer
+// major cannot silently downgrade. The local implementation supports only v1.0.
+func Negotiate(local, remote Hello) (Result, error) {
+ if err := local.validate(); err != nil {
+ return Result{}, err
+ }
+ if err := remote.validate(); err != nil {
+ return Result{}, err
+ }
+ if local.ProtocolMajor != Major || local.ProtocolMinor != Minor || remote.ProtocolMajor != Major {
+ return Result{}, ErrVersion
+ }
+ if local.Component == remote.Component || local.InstallationID != remote.InstallationID || local.SessionInstanceID == remote.SessionInstanceID {
+ return Result{}, ErrPeer
+ }
+ return Result{Peer: remote, ProtocolMinor: Minor}, nil
+}
+
+// Exchange performs one bounded handshake on an already OS-authenticated
+// connection. It owns the connection exclusively until returning, clears its
+// deadline on success, and closes on every failure. Cancellation interrupts a
+// blocked read/write. The caller owns and serializes the connection on success.
+func Exchange(ctx context.Context, conn net.Conn, local Hello) (result Result, err error) {
+ if conn == nil {
+ return Result{}, ErrTransport
+ }
+ canceled := make(chan struct{})
+ stop := context.AfterFunc(ctx, func() { _ = conn.Close(); close(canceled) })
+ defer func() {
+ if !stop() {
+ <-canceled
+ err = ctx.Err()
+ }
+ if err != nil {
+ result = Result{}
+ _ = conn.Close()
+ }
+ }()
+ if err := ctx.Err(); err != nil {
+ return Result{}, err
+ }
+ if err := local.validate(); err != nil {
+ return Result{}, err
+ }
+ if local.ProtocolMajor != Major || local.ProtocolMinor != Minor {
+ return Result{}, ErrVersion
+ }
+ deadline := time.Now().Add(Timeout)
+ if earlier, ok := ctx.Deadline(); ok && earlier.Before(deadline) {
+ deadline = earlier
+ }
+ if err := conn.SetDeadline(deadline); err != nil {
+ return Result{}, ErrTransport
+ }
+ payload, err := json.Marshal(local)
+ if err != nil {
+ return Result{}, ErrInvalid
+ }
+ if local.Component == Desktop {
+ if err := wire.WriteFrame(conn, payload); err != nil {
+ return Result{}, ErrTransport
+ }
+ }
+ received, err := wire.ReadFrame(conn)
+ if err != nil {
+ return Result{}, ErrTransport
+ }
+ remote, err := Decode(received)
+ if err != nil {
+ return Result{}, err
+ }
+ result, err = Negotiate(local, remote)
+ if err != nil {
+ return Result{}, err
+ }
+ if local.Component == Controller {
+ if err := wire.WriteFrame(conn, payload); err != nil {
+ return Result{}, ErrTransport
+ }
+ }
+ if err := conn.SetDeadline(time.Time{}); err != nil {
+ return Result{}, ErrTransport
+ }
+ return result, nil
+}
diff --git a/internal/sshcontrol/handshake/handshake_test.go b/internal/sshcontrol/handshake/handshake_test.go
new file mode 100644
index 00000000..2bc2b42b
--- /dev/null
+++ b/internal/sshcontrol/handshake/handshake_test.go
@@ -0,0 +1,206 @@
+package handshake
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Tutitoos/atenea/internal/sshcontrol/wire"
+)
+
+const installation = "c29b3c96-5b91-4a31-a6a4-c8df574acabb"
+
+func hello(t *testing.T, role string) Hello {
+ t.Helper()
+ h, err := New(role, installation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func TestExactSchema(t *testing.T) {
+ original := hello(t, Desktop)
+ payload, err := json.Marshal(original)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := Decode(payload)
+ if err != nil || decoded != original {
+ t.Fatalf("valid hello: %v", err)
+ }
+ s := string(payload)
+ inputs := []string{
+ strings.Replace(s, `"protocol_major":1`, `"protocol_major":1,"PROTOCOL_MAJOR":2`, 1),
+ strings.Replace(s, `"protocol_major"`, `"PROTOCOL_MAJOR"`, 1),
+ strings.Replace(s, `"protocol_minor":0,`, "", 1),
+ strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":null`, 1),
+ strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":65536`, 1),
+ strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":1e999`, 1),
+ strings.Replace(s, `"protocol_major":1`, `"protocol_major":1,"protocol_major":2`, 1),
+ strings.Replace(s, installation, strings.ToUpper(installation), 1),
+ strings.Replace(s, installation, "00000000-0000-0000-0000-000000000000", 1),
+ strings.Replace(s, `"desktop"`, `"DO_NOT_LOG"`, 1),
+ }
+ for i, input := range inputs {
+ got, err := Decode([]byte(input))
+ if !errors.Is(err, ErrInvalid) || got != (Hello{}) {
+ t.Fatalf("case %d: accepted malformed hello", i)
+ }
+ if strings.Contains(err.Error(), "DO_NOT_LOG") {
+ t.Fatal("error leaked input")
+ }
+ }
+}
+
+func TestNegotiation(t *testing.T) {
+ local, remote := hello(t, Desktop), hello(t, Controller)
+ remote.ProtocolMinor = 42
+ result, err := Negotiate(local, remote)
+ if err != nil || result.ProtocolMinor != 0 {
+ t.Fatalf("minor negotiation: %v", err)
+ }
+ cases := []struct {
+ name string
+ modify func(*Hello)
+ want error
+ }{
+ {"major", func(h *Hello) { h.ProtocolMajor = 2 }, ErrVersion},
+ {"role", func(h *Hello) { h.Component = Desktop }, ErrPeer},
+ {"installation", func(h *Hello) { h.InstallationID = "8221ecc6-e0de-42fd-b7eb-8edb5e5a0710" }, ErrPeer},
+ {"reflection", func(h *Hello) { h.SessionInstanceID = local.SessionInstanceID }, ErrPeer},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ changed := remote
+ tc.modify(&changed)
+ got, err := Negotiate(local, changed)
+ if !errors.Is(err, tc.want) || got != (Result{}) {
+ t.Fatalf("negotiation: %v", err)
+ }
+ })
+ }
+}
+
+func TestExchangePreservesConnectionForNextFrame(t *testing.T) {
+ desktop, controller := net.Pipe()
+ defer func() { _ = desktop.Close(); _ = controller.Close() }()
+ a, b := hello(t, Desktop), hello(t, Controller)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ result, err := Exchange(ctx, controller, b)
+ if err == nil && result.Peer != a {
+ err = ErrPeer
+ }
+ if err == nil {
+ err = wire.WriteFrame(controller, []byte(`{"fixture":"ready"}`))
+ }
+ done <- err
+ }()
+ result, err := Exchange(ctx, desktop, a)
+ if err != nil || result.Peer != b {
+ t.Fatalf("desktop: %v", err)
+ }
+ if err := desktop.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ payload, err := wire.ReadFrame(desktop)
+ if err != nil || string(payload) != `{"fixture":"ready"}` {
+ t.Fatalf("next frame: %v", err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+}
+
+type observedConn struct {
+ net.Conn
+ reading chan struct{}
+ once sync.Once
+}
+
+func (c *observedConn) Read(p []byte) (int, error) {
+ c.once.Do(func() { close(c.reading) })
+ return c.Conn.Read(p)
+}
+
+func TestCancellationClosesBlockedExchange(t *testing.T) {
+ a, b := net.Pipe()
+ defer func() { _ = a.Close(); _ = b.Close() }()
+ observed := &observedConn{Conn: a, reading: make(chan struct{})}
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ local := hello(t, Controller)
+ done := make(chan error, 1)
+ go func() { _, err := Exchange(ctx, observed, local); done <- err }()
+ select {
+ case <-observed.reading:
+ case <-time.After(2 * time.Second):
+ t.Fatal("exchange did not reach read")
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("cancellation did not interrupt read")
+ }
+ if _, err := b.Write([]byte{1}); err == nil {
+ t.Fatal("canceled exchange kept connection open")
+ }
+}
+
+func TestIncompatiblePeerCannotContinueConnection(t *testing.T) {
+ a, b := net.Pipe()
+ defer func() { _ = a.Close(); _ = b.Close() }()
+ local, remote := hello(t, Controller), hello(t, Desktop)
+ remote.ProtocolMajor = 2
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { _, err := Exchange(ctx, a, local); done <- err }()
+ payload, err := json.Marshal(remote)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := wire.WriteFrame(b, payload); err != nil {
+ t.Fatal(err)
+ }
+ if err := <-done; !errors.Is(err, ErrVersion) {
+ t.Fatalf("version: %v", err)
+ }
+ if err := wire.WriteFrame(b, []byte(`{"operation":"dispatch"}`)); err == nil {
+ t.Fatal("failed handshake left connection usable")
+ }
+}
+
+func FuzzDecode(f *testing.F) {
+ f.Add([]byte(`{"protocol_major":1,"protocol_minor":0,"component":"desktop","installation_id":"c29b3c96-5b91-4a31-a6a4-c8df574acabb","session_instance_id":"8221ecc6-e0de-42fd-b7eb-8edb5e5a0710"}`))
+ f.Add([]byte(`{"PROTOCOL_MAJOR":1}`))
+ f.Fuzz(func(t *testing.T, input []byte) {
+ h, err := Decode(input)
+ if err != nil {
+ if h != (Hello{}) {
+ t.Fatal("partial hello returned")
+ }
+ return
+ }
+ encoded, err := json.Marshal(h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ roundtrip, err := Decode(encoded)
+ if err != nil || roundtrip != h {
+ t.Fatalf("roundtrip: %v", err)
+ }
+ })
+}
diff --git a/internal/sshcontrol/localipc/endpoint.go b/internal/sshcontrol/localipc/endpoint.go
new file mode 100644
index 00000000..11c8aa85
--- /dev/null
+++ b/internal/sshcontrol/localipc/endpoint.go
@@ -0,0 +1,7 @@
+package localipc
+
+import "errors"
+
+// ErrEndpointTooLong means the user's state path cannot fit in a native Unix
+// socket address. The controller must refuse startup before leaving a lock.
+var ErrEndpointTooLong = errors.New("ssh local ipc: endpoint path too long")
diff --git a/internal/sshcontrol/localipc/localipc_unix.go b/internal/sshcontrol/localipc/localipc_unix.go
new file mode 100644
index 00000000..db194c02
--- /dev/null
+++ b/internal/sshcontrol/localipc/localipc_unix.go
@@ -0,0 +1,221 @@
+//go:build darwin || linux
+
+// Package localipc owns the Atenea SSH desktop/controller endpoint. Native
+// credential checks are separate from the version handshake: values sent by a
+// peer do not establish its OS identity.
+package localipc
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/Tutitoos/atenea/internal/ipc"
+ "github.com/Tutitoos/atenea/internal/pidlock"
+)
+
+const socketName = "atenea-ssh.sock"
+
+// The shared Unix listener uses the shortest supported sun_path limit.
+const maxSocketPath = 103
+
+var (
+ // ErrPrivateRoot means the endpoint's state root is unsafe for a user-owned socket.
+ ErrPrivateRoot = errors.New("ssh local ipc: private state root required")
+ // ErrPeer means the connected process is not the expected OS user.
+ ErrPeer = errors.New("ssh local ipc: peer identity mismatch")
+)
+
+// Listener accepts only same-UID peers. A separate controller must own this
+// listener and its state for the current logged-in account.
+type Listener struct {
+ inner *ipc.Listener
+ address *net.UnixAddr
+ release func()
+ once sync.Once
+}
+
+// Listen binds a private socket below root. The caller chooses a stable,
+// absolute, user-owned state root; it must not contain an untrusted symlink.
+func Listen(root string) (*Listener, error) {
+ if err := ensureRoot(root); err != nil {
+ return nil, err
+ }
+ if err := ValidateEndpoint(root); err != nil {
+ return nil, err
+ }
+ run := filepath.Join(root, "run")
+ if err := ensureRun(run); err != nil {
+ return nil, err
+ }
+ lock := filepath.Join(run, "atenea-ssh.lock")
+ if err := privateLock(lock); err != nil {
+ return nil, err
+ }
+ path := filepath.Join(run, socketName)
+ release, err := pidlock.Claim(lock)
+ if err != nil {
+ return nil, err
+ }
+ inner, err := ipc.Listen(path)
+ if err != nil {
+ release()
+ return nil, err
+ }
+ return &Listener{inner: inner, address: &net.UnixAddr{Name: path, Net: "unix"}, release: release}, nil
+}
+
+// Accept returns a connection whose peer UID was verified by the kernel.
+func (l *Listener) Accept() (net.Conn, error) { return l.inner.Accept() }
+
+// Close stops listening and removes the endpoint.
+func (l *Listener) Close() error {
+ err := l.inner.Close()
+ l.once.Do(l.release)
+ return err
+}
+
+// Addr identifies this user's socket.
+func (l *Listener) Addr() net.Addr { return l.address }
+
+// Dial verifies the private path, socket owner and server peer UID. It never
+// trusts an alias, path string or self-reported handshake identity as a login.
+func Dial(root string, timeout time.Duration) (net.Conn, error) {
+ if timeout <= 0 {
+ return nil, ErrPeer
+ }
+ if err := ValidateEndpoint(root); err != nil {
+ return nil, err
+ }
+ if err := privateDir(root); err != nil {
+ return nil, err
+ }
+ run := filepath.Join(root, "run")
+ if err := privateDir(run); err != nil {
+ return nil, err
+ }
+ path := filepath.Join(run, socketName)
+ info, err := os.Lstat(path)
+ if err != nil {
+ return nil, err
+ }
+ if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0o077 != 0 || !ownedByUs(info) {
+ return nil, ErrPeer
+ }
+ conn, err := ipc.DialTimeout(path, timeout)
+ if err != nil {
+ return nil, err
+ }
+ unixConn, ok := conn.(*net.UnixConn)
+ if !ok {
+ _ = conn.Close()
+ return nil, ErrPeer
+ }
+ same, err := samePeer(unixConn)
+ if err != nil || !same {
+ _ = conn.Close()
+ return nil, ErrPeer
+ }
+ // Refuse a socket path swapped during the connection. The peer credential is
+ // authoritative, and this second check preserves the private endpoint route.
+ after, err := os.Lstat(path)
+ if err != nil || !os.SameFile(info, after) {
+ _ = conn.Close()
+ return nil, ErrPeer
+ }
+ return conn, nil
+}
+
+func ensureRoot(root string) error {
+ if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root {
+ return ErrPrivateRoot
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return err
+ }
+ info, err := os.Lstat(root)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || !ownedByUs(info) {
+ return ErrPrivateRoot
+ }
+ // The dedicated root may have been created under a permissive umask. Tighten
+ // it before the socket is bound; never chmod a symlink or another owner.
+ if err := os.Chmod(root, 0o700); err != nil {
+ return err
+ }
+ return privateDir(root)
+}
+
+func privateDir(path string) error {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || info.Mode().Perm()&0o077 != 0 || !ownedByUs(info) {
+ return ErrPrivateRoot
+ }
+ return nil
+}
+
+func ensureRun(run string) error {
+ if err := os.Mkdir(run, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
+ return err
+ }
+ // Lstat rejects a pre-existing symlink before pidlock or ipc can follow it.
+ return privateDir(run)
+}
+
+func privateLock(path string) error {
+ info, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ st, ok := info.Sys().(*syscall.Stat_t)
+ if !ok || !info.Mode().IsRegular() || !ownedByUs(info) || info.Mode().Perm()&0o077 != 0 || st.Nlink != 1 {
+ return ErrPrivateRoot
+ }
+ return nil
+}
+
+func ownedByUs(info os.FileInfo) bool {
+ st, ok := info.Sys().(*syscall.Stat_t)
+ return ok && st.Uid == uint32(os.Geteuid())
+}
+
+// Endpoint returns the socket path for diagnostics and tests, not for access
+// control. Dial still rechecks filesystem and kernel peer identity.
+func Endpoint(root string) string { return filepath.Join(root, "run", socketName) }
+
+// ValidateEndpoint rejects a path that the kernel cannot bind as a socket.
+func ValidateEndpoint(root string) error {
+ if len(Endpoint(root)) > maxSocketPath {
+ return ErrEndpointTooLong
+ }
+ return nil
+}
+
+// Refused reports connections rejected by the native server peer check.
+func (l *Listener) Refused() int64 { return l.inner.Refused() }
+
+var _ net.Listener = (*Listener)(nil)
+
+// CheckRoot reports configuration errors without creating an endpoint.
+func CheckRoot(root string) error {
+ if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root {
+ return ErrPrivateRoot
+ }
+ if err := privateDir(root); err != nil {
+ return fmt.Errorf("ssh local ipc: %w", err)
+ }
+ return nil
+}
diff --git a/internal/sshcontrol/localipc/localipc_unix_test.go b/internal/sshcontrol/localipc/localipc_unix_test.go
new file mode 100644
index 00000000..4553d0e2
--- /dev/null
+++ b/internal/sshcontrol/localipc/localipc_unix_test.go
@@ -0,0 +1,298 @@
+//go:build darwin || linux
+
+package localipc
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Tutitoos/atenea/internal/pidlock"
+ "github.com/Tutitoos/atenea/internal/sshcontrol/handshake"
+)
+
+func TestLongEndpointFailsBeforeSocketSideEffects(t *testing.T) {
+ root := filepath.Join(t.TempDir(), strings.Repeat("x", maxSocketPath))
+ if err := ValidateEndpoint(root); !errors.Is(err, ErrEndpointTooLong) {
+ t.Fatalf("endpoint validation: %v", err)
+ }
+ if _, err := Listen(root); !errors.Is(err, ErrEndpointTooLong) {
+ t.Fatalf("listener: %v", err)
+ }
+ if _, err := Dial(root, time.Second); !errors.Is(err, ErrEndpointTooLong) {
+ t.Fatalf("dial: %v", err)
+ }
+ if _, err := os.Lstat(filepath.Join(root, "run")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("long endpoint created a socket directory: %v", err)
+ }
+}
+
+func TestNativeSocketAndSingleOwner(t *testing.T) {
+ root := shortRoot(t)
+ listener, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listener.Close() }()
+ done := make(chan error, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err != nil {
+ done <- err
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ var data [1]byte
+ if _, err = io.ReadFull(conn, data[:]); err == nil && data[0] != 42 {
+ err = ErrPeer
+ }
+ if err == nil {
+ _, err = conn.Write([]byte{43})
+ }
+ done <- err
+ }()
+ conn, err := Dial(root, time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close() }()
+ if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := conn.Write([]byte{42}); err != nil {
+ t.Fatal(err)
+ }
+ var data [1]byte
+ if _, err := io.ReadFull(conn, data[:]); err != nil || data[0] != 43 {
+ t.Fatalf("reply: %v", err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("accept did not finish")
+ }
+ if listener.Refused() != 0 {
+ t.Fatal("same user was refused")
+ }
+ if _, err := Listen(root); !errors.Is(err, pidlock.ErrHeld) {
+ t.Fatalf("second owner: %v", err)
+ }
+}
+
+func TestPrivateRootAndSocketMode(t *testing.T) {
+ t.Run("relative", func(t *testing.T) {
+ if _, err := Listen("relative"); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatal(err)
+ }
+ })
+ t.Run("world-readable", func(t *testing.T) {
+ root := shortRoot(t)
+ if err := os.Chmod(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ listener, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listener.Close() }()
+ info, err := os.Lstat(root)
+ if err != nil || info.Mode().Perm() != 0o700 {
+ t.Fatalf("root was not tightened: %v", err)
+ }
+ if err := os.Chmod(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Dial(root, time.Second); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatalf("widened root accepted: %v", err)
+ }
+ })
+ t.Run("symlink", func(t *testing.T) {
+ root := shortRoot(t)
+ link := filepath.Join(t.TempDir(), "linked")
+ if err := os.Symlink(root, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Listen(link); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatal(err)
+ }
+ })
+ t.Run("run symlink", func(t *testing.T) {
+ root := shortRoot(t)
+ target := shortRoot(t)
+ if err := os.Symlink(target, filepath.Join(root, "run")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatalf("run symlink accepted: %v", err)
+ }
+ if _, err := os.Lstat(filepath.Join(target, "atenea-ssh.lock")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("lock created outside private root: %v", err)
+ }
+ })
+ t.Run("lock symlink", func(t *testing.T) {
+ root := shortRoot(t)
+ run := filepath.Join(root, "run")
+ if err := os.Mkdir(run, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(root, "numeric-file")
+ if err := os.WriteFile(target, []byte("99999999"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, filepath.Join(run, "atenea-ssh.lock")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatalf("lock symlink accepted: %v", err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil || string(got) != "99999999" {
+ t.Fatalf("linked file changed: %q, %v", got, err)
+ }
+ })
+ t.Run("lock hardlink", func(t *testing.T) {
+ root := shortRoot(t)
+ run := filepath.Join(root, "run")
+ if err := os.Mkdir(run, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(root, "numeric-file")
+ if err := os.WriteFile(target, []byte("99999999"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Link(target, filepath.Join(run, "atenea-ssh.lock")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatalf("lock hardlink accepted: %v", err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil || string(got) != "99999999" {
+ t.Fatalf("linked file changed: %q, %v", got, err)
+ }
+ })
+ t.Run("socket widened", func(t *testing.T) {
+ root := shortRoot(t)
+ listener, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listener.Close() }()
+ path := Endpoint(root)
+ if err := os.Chmod(path, 0o666); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = os.Chmod(path, 0o600) }()
+ if _, err := Dial(root, time.Second); !errors.Is(err, ErrPeer) {
+ t.Fatal(err)
+ }
+ })
+}
+
+func TestExistingFileAndRestart(t *testing.T) {
+ root := shortRoot(t)
+ run := filepath.Join(root, "run")
+ if err := os.Mkdir(run, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := Endpoint(root)
+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Listen(root); err == nil {
+ t.Fatal("overwrote existing file")
+ }
+ content, err := os.ReadFile(path)
+ if err != nil || string(content) != "keep" {
+ t.Fatalf("foreign file changed: %v", err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ listener, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := listener.Close(); err != nil {
+ t.Fatal(err)
+ }
+ listener, err = Listen(root)
+ if err != nil {
+ t.Fatalf("restart: %v", err)
+ }
+ if err := listener.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func shortRoot(t *testing.T) string {
+ t.Helper()
+ base, err := filepath.EvalSymlinks("/tmp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, err := os.MkdirTemp(base, "as-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(root) })
+ return root
+}
+
+func TestNativeConnectionAndProtocolHandshake(t *testing.T) {
+ root := shortRoot(t)
+ listener, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listener.Close() }()
+ controllerHello, err := handshake.New(handshake.Controller, "c29b3c96-5b91-4a31-a6a4-c8df574acabb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ desktopHello, err := handshake.New(handshake.Desktop, controllerHello.InstallationID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err != nil {
+ done <- err
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ result, err := handshake.Exchange(ctx, conn, controllerHello)
+ if err == nil && result.Peer != desktopHello {
+ err = ErrPeer
+ }
+ done <- err
+ }()
+ conn, err := Dial(root, time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close() }()
+ result, err := handshake.Exchange(ctx, conn, desktopHello)
+ if err != nil || result.Peer != controllerHello {
+ t.Fatalf("native handshake: %v", err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+}
diff --git a/internal/sshcontrol/localipc/localipc_windows.go b/internal/sshcontrol/localipc/localipc_windows.go
new file mode 100644
index 00000000..b8178a6c
--- /dev/null
+++ b/internal/sshcontrol/localipc/localipc_windows.go
@@ -0,0 +1,233 @@
+//go:build windows
+
+// Package localipc owns the Atenea SSH desktop/controller endpoint. Windows
+// pipe ACLs and peer process tokens are checked before protocol handshakes.
+package localipc
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "sync/atomic"
+ "time"
+
+ winio "github.com/Microsoft/go-winio"
+ "golang.org/x/sys/windows"
+)
+
+var (
+ // ErrPrivateRoot means an invalid installation scope was passed.
+ ErrPrivateRoot = errors.New("ssh local ipc: absolute installation root required")
+ // ErrPeer means the connected process is not this Windows account and session.
+ ErrPeer = errors.New("ssh local ipc: peer identity mismatch")
+)
+
+// Listener accepts named-pipe clients after a kernel process/token check.
+type Listener struct {
+ inner net.Listener
+ identity peerIdentity
+ refused atomic.Int64
+}
+
+type peerIdentity struct {
+ sid string
+ session uint32
+}
+
+// Listen creates one instance per installation/account/logon session. The pipe
+// DACL allows only the current user's SID; Accept independently verifies the
+// client process token and session. A GUI cannot run in session zero by default.
+func Listen(root string) (*Listener, error) {
+ path, id, err := pipePath(root)
+ if err != nil {
+ return nil, err
+ }
+ config := &winio.PipeConfig{SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;%s)", id.sid), InputBufferSize: 64 << 10, OutputBufferSize: 64 << 10}
+ inner, err := winio.ListenPipe(path, config)
+ if err != nil {
+ return nil, err
+ }
+ return &Listener{inner: inner, identity: id}, nil
+}
+
+// Accept discards connections whose pipe peer cannot be independently mapped
+// to the current account and graphical logon session.
+func (l *Listener) Accept() (net.Conn, error) {
+ for {
+ conn, err := l.inner.Accept()
+ if err != nil {
+ return nil, err
+ }
+ if verifyPeer(conn, l.identity, false) == nil {
+ return conn, nil
+ }
+ _ = conn.Close()
+ l.refused.Add(1)
+ }
+}
+
+// Close stops accepting and releases the pipe name.
+func (l *Listener) Close() error { return l.inner.Close() }
+
+// Addr identifies the named pipe.
+func (l *Listener) Addr() net.Addr { return l.inner.Addr() }
+
+// Refused reports connections rejected after the DACL check.
+func (l *Listener) Refused() int64 { return l.refused.Load() }
+
+// Dial verifies both the pipe's private name and the kernel-reported server
+// process token/session. The process cannot authenticate itself with a hello.
+func Dial(root string, timeout time.Duration) (net.Conn, error) {
+ if timeout <= 0 {
+ return nil, ErrPeer
+ }
+ path, id, err := pipePath(root)
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ conn, err := winio.DialPipeContext(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ if err := verifyPeer(conn, id, true); err != nil {
+ _ = conn.Close()
+ return nil, ErrPeer
+ }
+ return conn, nil
+}
+
+// Endpoint returns a name for diagnostics, never authority to connect.
+func Endpoint(root string) string {
+ path, _, err := pipePath(root)
+ if err != nil {
+ return ""
+ }
+ return path
+}
+
+// Windows named-pipe names have no Unix sun_path limit.
+func ValidateEndpoint(string) error { return nil }
+
+// CheckRoot validates the installation scope without opening a pipe.
+func CheckRoot(root string) error {
+ if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root {
+ return ErrPrivateRoot
+ }
+ return nil
+}
+
+func pipePath(root string) (string, peerIdentity, error) {
+ if err := CheckRoot(root); err != nil {
+ return "", peerIdentity{}, err
+ }
+ physicalID, err := rootIdentity(root)
+ if err != nil {
+ return "", peerIdentity{}, err
+ }
+ id, err := currentIdentity()
+ if err != nil {
+ return "", peerIdentity{}, err
+ }
+ // Directory file identity is stable across case, short-path and junction
+ // aliases. Hashing the path text would let two controllers own one store.
+ digest := sha256.Sum256([]byte(physicalID + ":" + id.sid))
+ name := `\\.\pipe\atenea-ssh-` + hex.EncodeToString(digest[:12]) + fmt.Sprintf("-%d", id.session)
+ return name, id, nil
+}
+
+func rootIdentity(root string) (string, error) {
+ path, err := windows.UTF16PtrFromString(root)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err)
+ }
+ handle, err := windows.CreateFile(path, windows.FILE_READ_ATTRIBUTES,
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
+ nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err)
+ }
+ defer windows.CloseHandle(handle)
+ var info windows.ByHandleFileInformation
+ if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
+ return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err)
+ }
+ if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 ||
+ (info.VolumeSerialNumber == 0 && info.FileIndexHigh == 0 && info.FileIndexLow == 0) {
+ return "", ErrPrivateRoot
+ }
+ return fmt.Sprintf("%08x-%08x-%08x", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil
+}
+
+func currentIdentity() (peerIdentity, error) {
+ token, err := windows.OpenCurrentProcessToken()
+ if err != nil {
+ return peerIdentity{}, err
+ }
+ defer token.Close()
+ user, err := token.GetTokenUser()
+ if err != nil {
+ return peerIdentity{}, err
+ }
+ var session uint32
+ if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &session); err != nil {
+ return peerIdentity{}, err
+ }
+ if session == 0 {
+ return peerIdentity{}, ErrPeer
+ }
+ return peerIdentity{sid: user.User.Sid.String(), session: session}, nil
+}
+
+func verifyPeer(conn net.Conn, want peerIdentity, server bool) error {
+ withHandle, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return ErrPeer
+ }
+ handle := windows.Handle(withHandle.Fd())
+ var pid uint32
+ var err error
+ if server {
+ err = windows.GetNamedPipeServerProcessId(handle, &pid)
+ } else {
+ err = windows.GetNamedPipeClientProcessId(handle, &pid)
+ }
+ if err != nil || pid == 0 {
+ return ErrPeer
+ }
+ process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+ if err != nil {
+ return ErrPeer
+ }
+ defer windows.CloseHandle(process)
+ var token windows.Token
+ if err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token); err != nil {
+ return ErrPeer
+ }
+ defer token.Close()
+ user, err := token.GetTokenUser()
+ if err != nil || user == nil || user.User.Sid == nil || user.User.Sid.String() != want.sid {
+ return ErrPeer
+ }
+ var session uint32
+ if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != want.session {
+ return ErrPeer
+ }
+ var again uint32
+ if server {
+ err = windows.GetNamedPipeServerProcessId(handle, &again)
+ } else {
+ err = windows.GetNamedPipeClientProcessId(handle, &again)
+ }
+ if err != nil || again != pid {
+ return ErrPeer
+ }
+ return nil
+}
+
+var _ net.Listener = (*Listener)(nil)
diff --git a/internal/sshcontrol/localipc/localipc_windows_test.go b/internal/sshcontrol/localipc/localipc_windows_test.go
new file mode 100644
index 00000000..76ab9e82
--- /dev/null
+++ b/internal/sshcontrol/localipc/localipc_windows_test.go
@@ -0,0 +1,61 @@
+//go:build windows
+
+package localipc
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRootIdentityUsesDirectoryNotPathSpelling(t *testing.T) {
+ root := t.TempDir()
+ want, err := rootIdentity(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ caseAlias := strings.ToUpper(root)
+ got, err := rootIdentity(caseAlias)
+ if err != nil || got != want {
+ t.Fatalf("case alias has a different identity: %q, %v", got, err)
+ }
+ t.Run("symlink alias", func(t *testing.T) {
+ alias := filepath.Join(t.TempDir(), "alias")
+ if err := os.Symlink(root, alias); err != nil {
+ t.Skipf("directory symlink requires permission on this Windows runner: %v", err)
+ }
+ got, err := rootIdentity(alias)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Fatalf("one installation has two pipe identities: %q and %q", want, got)
+ }
+ })
+}
+
+func TestRootIdentityRejectsNonDirectory(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := rootIdentity(file); !errors.Is(err, ErrPrivateRoot) {
+ t.Fatalf("ordinary file accepted as an installation root: %v", err)
+ }
+}
+
+func TestNamedPipeHasOneOwner(t *testing.T) {
+ root := t.TempDir()
+ first, err := Listen(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = first.Close() }()
+ second, err := Listen(root)
+ if err == nil {
+ _ = second.Close()
+ t.Fatal("second controller took the same endpoint")
+ }
+}
diff --git a/internal/sshcontrol/localipc/peer_darwin.go b/internal/sshcontrol/localipc/peer_darwin.go
new file mode 100644
index 00000000..fe4a0eff
--- /dev/null
+++ b/internal/sshcontrol/localipc/peer_darwin.go
@@ -0,0 +1,26 @@
+//go:build darwin
+
+package localipc
+
+import (
+ "net"
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+func samePeer(conn *net.UnixConn) (bool, error) {
+ raw, err := conn.SyscallConn()
+ if err != nil {
+ return false, err
+ }
+ var cred *unix.Xucred
+ var readErr error
+ if err := raw.Control(func(fd uintptr) { cred, readErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) }); err != nil {
+ return false, err
+ }
+ if readErr != nil {
+ return false, readErr
+ }
+ return cred != nil && cred.Uid == uint32(os.Geteuid()), nil
+}
diff --git a/internal/sshcontrol/localipc/peer_linux.go b/internal/sshcontrol/localipc/peer_linux.go
new file mode 100644
index 00000000..1669de23
--- /dev/null
+++ b/internal/sshcontrol/localipc/peer_linux.go
@@ -0,0 +1,27 @@
+//go:build linux
+
+package localipc
+
+import (
+ "net"
+ "os"
+ "syscall"
+)
+
+func samePeer(conn *net.UnixConn) (bool, error) {
+ raw, err := conn.SyscallConn()
+ if err != nil {
+ return false, err
+ }
+ var cred *syscall.Ucred
+ var readErr error
+ if err := raw.Control(func(fd uintptr) {
+ cred, readErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
+ }); err != nil {
+ return false, err
+ }
+ if readErr != nil {
+ return false, readErr
+ }
+ return cred != nil && int(cred.Uid) == os.Geteuid(), nil
+}
diff --git a/internal/sshcontrol/wire/frame.go b/internal/sshcontrol/wire/frame.go
new file mode 100644
index 00000000..a398d0bd
--- /dev/null
+++ b/internal/sshcontrol/wire/frame.go
@@ -0,0 +1,171 @@
+// Package wire implements the bounded JSON framing shared by the SSH desktop
+// app, its local controller, and the remote connector. Framing is not
+// authentication: callers must separately verify the transport peer and validate
+// operation-specific schemas before invoking any action. Transport owners must
+// also impose read/write deadlines and connection concurrency limits.
+package wire
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "io"
+ "unicode/utf8"
+)
+
+const (
+ // MaxFrameBytes is checked before allocating the payload buffer.
+ MaxFrameBytes = 1 << 20
+ // MaxDepth bounds object/array nesting independently of the byte limit.
+ MaxDepth = 64
+)
+
+var (
+ // ErrFrameSize reports a zero-length or oversized frame.
+ ErrFrameSize = errors.New("ssh wire: invalid frame size")
+ // ErrInvalidJSON reports malformed or ambiguous JSON without payload text.
+ ErrInvalidJSON = errors.New("ssh wire: invalid JSON object")
+)
+
+// ReadFrame reads exactly one frame. On any non-EOF error the caller must close
+// the connection, rather than attempt to resynchronize or dispatch partial data.
+// JSON errors deliberately omit payload fragments, which may contain prompts.
+func ReadFrame(r io.Reader) ([]byte, error) {
+ var header [4]byte
+ if _, err := io.ReadFull(r, header[:]); err != nil {
+ return nil, err
+ }
+ size := binary.BigEndian.Uint32(header[:])
+ if size == 0 || size > MaxFrameBytes {
+ return nil, ErrFrameSize
+ }
+ payload := make([]byte, int(size))
+ if _, err := io.ReadFull(r, payload); err != nil {
+ if errors.Is(err, io.EOF) {
+ err = io.ErrUnexpectedEOF
+ }
+ return nil, err
+ }
+ if err := Validate(payload); err != nil {
+ return nil, err
+ }
+ return payload, nil
+}
+
+// WriteFrame validates before writing any bytes. A partial transport write is an
+// uncertain delivery, not permission to replay an execution on a new connection.
+// One writer must serialize frames for a connection; this function does not lock.
+func WriteFrame(w io.Writer, payload []byte) error {
+ if err := Validate(payload); err != nil {
+ return err
+ }
+ var header [4]byte
+ binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
+ if err := writeAll(w, header[:]); err != nil {
+ return err
+ }
+ return writeAll(w, payload)
+}
+
+func writeAll(w io.Writer, data []byte) error {
+ for len(data) > 0 {
+ n, err := w.Write(data)
+ if n < 0 || n > len(data) {
+ return io.ErrShortWrite
+ }
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return io.ErrShortWrite
+ }
+ data = data[n:]
+ }
+ return nil
+}
+
+// Validate accepts one UTF-8 JSON object, rejecting duplicate keys at every
+// nesting level (including escaped equivalents), extra JSON values and excessive
+// depth. It does not deserialize numbers into floating point values.
+func Validate(payload []byte) error {
+ if len(payload) == 0 || len(payload) > MaxFrameBytes {
+ return ErrFrameSize
+ }
+ if !utf8.Valid(payload) {
+ return ErrInvalidJSON
+ }
+ decoder := json.NewDecoder(bytes.NewReader(payload))
+ decoder.UseNumber()
+ token, err := decoder.Token()
+ if err != nil || token != json.Delim('{') {
+ return ErrInvalidJSON
+ }
+ if err := object(decoder, 1); err != nil {
+ return err
+ }
+ if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
+ return ErrInvalidJSON
+ }
+ return nil
+}
+
+func object(decoder *json.Decoder, depth int) error {
+ if depth > MaxDepth {
+ return ErrInvalidJSON
+ }
+ keys := make(map[string]struct{})
+ for decoder.More() {
+ token, err := decoder.Token()
+ if err != nil {
+ return ErrInvalidJSON
+ }
+ key, ok := token.(string)
+ if !ok {
+ return ErrInvalidJSON
+ }
+ if _, exists := keys[key]; exists {
+ return ErrInvalidJSON
+ }
+ keys[key] = struct{}{}
+ if err := value(decoder, depth); err != nil {
+ return err
+ }
+ }
+ token, err := decoder.Token()
+ if err != nil || token != json.Delim('}') {
+ return ErrInvalidJSON
+ }
+ return nil
+}
+
+func value(decoder *json.Decoder, depth int) error {
+ token, err := decoder.Token()
+ if err != nil {
+ return ErrInvalidJSON
+ }
+ delimiter, ok := token.(json.Delim)
+ if !ok {
+ return nil
+ }
+ switch delimiter {
+ case '{':
+ return object(decoder, depth+1)
+ case '[':
+ if depth+1 > MaxDepth {
+ return ErrInvalidJSON
+ }
+ for decoder.More() {
+ if err := value(decoder, depth+1); err != nil {
+ return err
+ }
+ }
+ end, err := decoder.Token()
+ if err != nil || end != json.Delim(']') {
+ return ErrInvalidJSON
+ }
+ return nil
+ default:
+ return ErrInvalidJSON
+ }
+}
diff --git a/internal/sshcontrol/wire/frame_test.go b/internal/sshcontrol/wire/frame_test.go
new file mode 100644
index 00000000..2ee4e704
--- /dev/null
+++ b/internal/sshcontrol/wire/frame_test.go
@@ -0,0 +1,144 @@
+package wire
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestFramesPreserveBytesAndBoundaries(t *testing.T) {
+ first := []byte(`{"prompt":"literal spacing \n café","body":{"n":1}}`)
+ second := []byte(`{"operation":"status"}`)
+ var stream bytes.Buffer
+ for _, payload := range [][]byte{first, second} {
+ if err := WriteFrame(&stream, payload); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, want := range [][]byte{first, second} {
+ got, err := ReadFrame(&stream)
+ if err != nil || !bytes.Equal(got, want) {
+ t.Fatalf("round trip: %v", err)
+ }
+ }
+ if _, err := ReadFrame(&stream); !errors.Is(err, io.EOF) {
+ t.Fatalf("end: %v", err)
+ }
+}
+
+func TestInvalidFramesWriteNothing(t *testing.T) {
+ cases := []string{
+ "", `[]`, `null`, `{"a":1,"a":2}`, `{"a":1,"\u0061":2}`,
+ `{"nested":[{"x":1,"x":2}]}`, `{"ok":1} {"extra":2}`,
+ `{"secret":"DO_NOT_LOG",}`, `{"n":NaN}`, `{"n":01}`, "{\"x\":\"\xff\"}",
+ `{"x":` + strings.Repeat("[", MaxDepth) + "0" + strings.Repeat("]", MaxDepth) + "}",
+ `{"x":"` + strings.Repeat("a", MaxFrameBytes) + `"}`,
+ }
+ for index, input := range cases {
+ var dst bytes.Buffer
+ err := WriteFrame(&dst, []byte(input))
+ if err == nil || dst.Len() != 0 {
+ t.Fatalf("case %d accepted or wrote partial frame", index)
+ }
+ if strings.Contains(err.Error(), "DO_NOT_LOG") {
+ t.Fatal("payload in error")
+ }
+ }
+}
+
+type headerOnlyReader struct {
+ header *bytes.Reader
+ payloadReads int
+}
+
+func (r *headerOnlyReader) Read(p []byte) (int, error) {
+ if r.header.Len() == 0 {
+ r.payloadReads++
+ return 0, errors.New("payload must not be read")
+ }
+ return r.header.Read(p)
+}
+
+func TestOversizeRejectedBeforePayloadRead(t *testing.T) {
+ for _, size := range []uint32{0, MaxFrameBytes + 1, ^uint32(0)} {
+ var header [4]byte
+ binary.BigEndian.PutUint32(header[:], size)
+ reader := &headerOnlyReader{header: bytes.NewReader(header[:])}
+ payload, err := ReadFrame(reader)
+ if !errors.Is(err, ErrFrameSize) || payload != nil || reader.payloadReads != 0 {
+ t.Fatal("invalid length reached payload reader")
+ }
+ }
+}
+
+func TestTruncationNeverReturnsPayload(t *testing.T) {
+ var stream bytes.Buffer
+ if err := WriteFrame(&stream, []byte(`{"prompt":"private"}`)); err != nil {
+ t.Fatal(err)
+ }
+ complete := stream.Bytes()
+ for n := 1; n < len(complete); n++ {
+ payload, err := ReadFrame(bytes.NewReader(complete[:n]))
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("prefix %d: %v", n, err)
+ }
+ if payload != nil {
+ t.Fatal("returned truncated payload")
+ }
+ }
+}
+
+type shortWriter struct{ bytes.Buffer }
+
+func (w *shortWriter) Write(p []byte) (int, error) {
+ if len(p) > 2 {
+ p = p[:2]
+ }
+ return w.Buffer.Write(p)
+}
+
+type stuckWriter struct{}
+
+func (stuckWriter) Write([]byte) (int, error) { return 0, nil }
+
+func TestShortWritesAndStalledWriter(t *testing.T) {
+ var writer shortWriter
+ payload := []byte(`{"operation":"hello"}`)
+ if err := WriteFrame(&writer, payload); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadFrame(&writer)
+ if err != nil || !bytes.Equal(got, payload) {
+ t.Fatalf("short writes: %v", err)
+ }
+ if err := WriteFrame(stuckWriter{}, payload); !errors.Is(err, io.ErrShortWrite) {
+ t.Fatalf("stalled writer: %v", err)
+ }
+}
+
+func FuzzReadFrame(f *testing.F) {
+ f.Add([]byte{0, 0, 0, 2, '{', '}'})
+ f.Add([]byte{255, 255, 255, 255})
+ f.Fuzz(func(t *testing.T, input []byte) {
+ payload, err := ReadFrame(bytes.NewReader(input))
+ if err != nil {
+ if payload != nil {
+ t.Fatal("partial payload on error")
+ }
+ return
+ }
+ if len(payload) > MaxFrameBytes {
+ t.Fatal("oversized frame")
+ }
+ var output bytes.Buffer
+ if err := WriteFrame(&output, payload); err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.HasPrefix(input, output.Bytes()) {
+ t.Fatal("framing changed input")
+ }
+ })
+}