Skip to content

feat(setup): install hsh-tunneld automatically [DEP-141] - #34

Merged
racerxdl merged 1 commit into
mainfrom
dep-141
Sep 4, 2026
Merged

racerxdl merged 1 commit into
mainfrom
dep-141

Conversation

@racerxdl

Copy link
Copy Markdown
Contributor

Installs the hsh-tunneld daemon automatically so a fresh hsh reaches a working tunnel without the user knowing a second binary exists (DEP-141).

  • New hsh setup [--force] downloads the OS/arch-matched daemon from the hoophq/hoop release pinned by BUNDLED_DAEMON_VERSION, then delegates registration to sudo hsh-tunneld install — no reimplementation of the systemd unit / LaunchDaemon logic that already has Go-side test coverage. It also runs implicitly on first hsh login; --no-setup opts out.
  • Checksum verification is mandatory, with no warn-and-continue fallback: this artifact runs as root, unlike hsh update which only replaces the unprivileged CLI. A failed verify deletes any previously staged binary, so a stale daemon can never survive a failed install. hsh never reads the password — sudo prompts on the inherited TTY, keeping Touch ID/PAM intact.
  • Verified end to end against the real release: 18 MB hsh-tunneld-darwin-arm64 downloaded, sha256 5512b0ed… matched against the published SHA256SUMS, sudo argv correct, non-zero installer exit surfaced as a typed error. 15 new tests (tampered asset discarded, stale binary removed, missing manifest/entry fatal, exit-code propagation); bunx tsc --noEmit clean; 466 pass.

Worth reviewer attention: implicit setup is gated on a /dev/tty probe rather than process.stdin.isTTY, because sudo reads the password from /dev/tty — I confirmed sudo still prompts and blocks with stdin at /dev/null, so the stdin check both skipped setup wrongly for hsh login < file and failed to protect the CI case it was meant to.

Automated by MisterMal

hsh now fetches the OS/arch-matched daemon, verifies it against the
release SHA256SUMS, and hands it to the system service manager via sudo.
Runs implicitly on first `hsh login`; `--no-setup` opts out.

🤖 Generated with Mister Maluco

Co-authored-by: MisterMal <teskeslab@lucasteske.dev>
@racerxdl

Copy link
Copy Markdown
Contributor Author

Tested on Linux and macOS.

Automated by MisterMal

@racerxdl
racerxdl marked this pull request as ready for review August 24, 2026 17:24
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Install hsh-tunneld automatically via new hsh setup and implicit login flow

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add hsh setup [--force] to download, verify, and install hsh-tunneld.
• Run setup implicitly on first hsh login, with --no-setup opt-out.
• Enforce mandatory SHA256SUMS verification before any privileged installation.
Diagram

graph TD
U(["User"]) --> CLI["hsh CLI (login/setup)"] --> INST["installer.ts"] --> GH{{"GitHub Releases"}}
INST --> STAGE["staged daemon"] --> SUDO["sudo / root"] --> SVC["hsh-tunneld service"]
CLI --> IPC["Tunnel IPC client"] --> SVC
subgraph Legend
direction LR
_u(["User"]) ~~~ _p["CLI/module"] ~~~ _e{{"External"}}
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Package-manager-managed daemon (brew/apt/yum/nix)
  • ➕ Avoids ad-hoc downloads at runtime and leverages existing distro trust chains
  • ➕ Cleaner lifecycle management (upgrades, removal, service enable/disable)
  • ➖ Doesn’t help users who install via bare binary download/copy
  • ➖ Requires per-platform packaging work and coordination across repos
2. Cryptographic signing (Sigstore/cosign or GPG) instead of SHA256SUMS
  • ➕ Stronger provenance guarantees than a checksum manifest fetched from the same host
  • ➕ Easier to reason about trust if the signing root is pinned in the client
  • ➖ More operational complexity (signing, key management, verification UX)
  • ➖ May require additional dependencies or verification code paths
3. Reimplement service registration in the CLI
  • ➕ Could remove dependency on sudo install behavior and flags
  • ➕ Potentially more controllable UX across OSes
  • ➖ Duplicates systemd/LaunchDaemon logic already implemented and tested in Go
  • ➖ Higher long-term maintenance risk and more platform-specific edge cases

Recommendation: The PR’s approach is the best near-term tradeoff: keep service registration in hsh-tunneld install (single source of truth) and focus the CLI on secure acquisition/verification plus orchestration. The mandatory checksum gate and deletion of any previously staged binary on verify failure are appropriate for a root-executed artifact. Longer term, consider adding artifact signing verification (e.g., cosign) to strengthen the trust model beyond “checksum manifest from the same base URL”.

Files changed (7) +964 / -21

Enhancement (4) +579 / -4
login.tsAdd implicit daemon setup during login with '--no-setup' opt-out +58/-4

Add implicit daemon setup during login with '--no-setup' opt-out

• Adds a '/dev/tty'-based controlling-terminal probe to decide whether implicit setup can safely run without hanging. Introduces '--no-setup', gates implicit install on platform support and absence of an existing daemon, and preserves best-effort daemon login semantics while surfacing setup failure via exit code.

src/commands/login.ts

setup.tsIntroduce 'hsh setup' command and shared setup flow helpers +125/-0

Introduce 'hsh setup' command and shared setup flow helpers

• Implements 'hsh setup [--force]' with consistent banner/output, idempotent detection of existing installation, and structured error handling. After installation, best-effort configures the daemon’s gateway via IPC and prints next-step guidance accounting for group membership propagation.

src/commands/setup.ts

index.tsRegister the new 'setup' command in the CLI entry point +2/-0

Register the new 'setup' command in the CLI entry point

• Wires 'setupCommand' into the commander program so 'hsh setup' is available alongside existing commands.

src/index.ts

installer.tsImplement secure daemon download, checksum verification, and sudo install +394/-0

Implement secure daemon download, checksum verification, and sudo install

• Adds the DEP-141 installer module that resolves OS/arch targets, fetches 'SHA256SUMS', and downloads/stages the matching 'hsh-tunneld' asset with mandatory SHA256 verification. Delegates privileged service registration to 'sudo <staged-binary> install', propagating failures as typed errors with remediation hints and handling unsupported platforms (notably Windows service install).

src/tunnel/installer.ts

Tests (2) +355 / -0
login-command-options.test.tsTest default behavior for new '--no-setup' negatable option +4/-0

Test default behavior for new '--no-setup' negatable option

• Extends the negatable option contract tests to ensure 'setup' defaults to true for 'hsh login'.

tests/login-command-options.test.ts

tunnel-installer.test.tsAdd contract tests for daemon installer trust and sudo execution +351/-0

Add contract tests for daemon installer trust and sudo execution

• Introduces end-to-end style tests using a local HTTP stub for release assets and fake 'sudo' wrappers to validate security invariants (mandatory manifest, entry presence, tamper discard, stale-binary removal). Verifies argument passthrough and exit-code propagation for the privileged install step, and checks progress milestone ordering for the full install flow.

tests/tunnel-installer.test.ts

Documentation (1) +30 / -17
README.mdDocument two-step install with 'hsh setup' and implicit setup behavior +30/-17

Document two-step install with 'hsh setup' and implicit setup behavior

• Restructures install instructions to separate CLI installation from networking components setup. Introduces 'hsh setup' as the supported way to download/verify/install 'hsh-tunneld', documents implicit setup on first 'hsh login', and clarifies Windows limitations and existing manual install options.

README.md

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (1) 📎 Requirement gaps (0) 📜 Skill insights (0)

Grey Divider


Action required

1. TOCTOU root binary exec 🐞 Bug ⛨ Security
Description
installDaemon downloads hsh-tunneld into a user-writable hsh state dir and then executes that staged
path via sudo; another process running as the same user can swap the file between verification and
sudo exec, leading to arbitrary code execution as root when the user enters their password.
Code

src/tunnel/installer.ts[R383-386]

+  progress(`Verified ${downloaded.assetName} (sha256 ${downloaded.sha256})`);
+  progress("Registering the daemon with your system service manager (sudo required)");
+  runPrivilegedInstall(downloaded.path, opts.installArgs);
+
Evidence
The PR stages the daemon under the user’s hsh home (typically ~/.hsh) and then executes that exact
path under sudo. This creates a time-of-check/time-of-use window between the unprivileged checksum
verification and the privileged exec.

src/tunnel/installer.ts[161-165]
src/config/store.ts[19-23]
src/tunnel/installer.ts[231-256]
src/tunnel/installer.ts[280-295]
src/tunnel/installer.ts[383-386]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`hsh setup` verifies the downloaded daemon while writing it to `~/.hsh/bin/hsh-tunneld`, but later runs that *path* as root (`sudo <path> install`). Because the staged file is user-writable, another process running as the same user can replace it after verification (including while the user is typing their sudo password). That creates a TOCTOU window where unverified bytes may execute as root.

### Issue Context
- The staging location is inside the user’s hsh home directory.
- The privileged step executes the staged file directly via `sudo`.

### Fix Focus Areas
- src/tunnel/installer.ts[161-165]
- src/tunnel/installer.ts[280-295]
- src/tunnel/installer.ts[383-386]

### Implementation guidance
Implement a privileged “verify-then-exec” step so the hash check is performed *inside* the sudo session on a root-owned (non-user-modifiable) file:
1. Change `downloadDaemon()` (or `installDaemon()`) to return the expected SHA as well (or compute it again).
2. In `runPrivilegedInstall`, when not already root:
  - Run a single `sudo` command that:
    - `chown root:root` + `chmod 0755` the staged file **first** (so the user can’t modify it anymore),
    - recomputes and checks sha256 against the expected value (use `sha256sum` on Linux and `shasum -a 256` on macOS, or detect availability),
    - only then `exec`s `"$daemonPath" install ...`.
3. If the checksum verification fails inside sudo, abort and delete the staged file.

This closes the TOCTOU window without requiring changes to the Go daemon installer logic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. setup.ts missing Command export 📘 Rule violation ≡ Correctness
Description
The new command file exports setupCommand but does not export a Command object as required,
making command exports inconsistent across src/commands. This can break conventions/tools that
rely on a standard Command export name.
Code

src/commands/setup.ts[R119-120]

+export const setupCommand = new Command("setup")
+  .description("Install the Hoop networking components (hsh-tunneld system service)")
Evidence
PR Compliance ID 2219516 requires every command file to create and export a Command object named
Command. The new src/commands/setup.ts exports setupCommand and contains no `export const
Command = ...`.

Rule 2219516: Export Command object from each command file
src/commands/setup.ts[119-125]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Compliance rule requires each file under `src/commands/` to define and export a value named `Command`. The new `src/commands/setup.ts` instead exports `setupCommand`.

## Issue Context
`src/index.ts` currently imports `setupCommand` from `./commands/setup.ts`.

## Fix Focus Areas
- src/commands/setup.ts[119-125]
- src/index.ts[4-4]
- src/index.ts[27-29]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Untrusted daemon source override 🐞 Bug ⛨ Security
Description
HSH_DAEMON_RELEASE_BASE lets callers redirect both the manifest and daemon download to an arbitrary
base URL, so an attacker who can influence the environment can change the trust root and cause
installation of a root-executed binary from a non-GitHub host.
Code

src/tunnel/installer.ts[R76-81]

+function downloadBase(): string {
+  const override = process.env.HSH_DAEMON_RELEASE_BASE;
+  if (override && override.trim() !== "") {
+    return override.trim().replace(/\/+$/, "");
+  }
+  return "https://github.com";
Evidence
The installer selects its download origin from an environment variable and uses it to fetch both the
checksum manifest and the daemon asset; that directly controls what binary is later executed with
sudo.

src/tunnel/installer.ts[64-82]
src/tunnel/installer.ts[190-210]
src/tunnel/installer.ts[231-240]
src/tunnel/installer.ts[383-386]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`downloadBase()` honors `HSH_DAEMON_RELEASE_BASE` and uses it for both `SHA256SUMS` and the daemon asset. This effectively makes the trust root configurable at runtime for code that will be executed as root.

### Issue Context
Even if the checksum is verified, allowing an arbitrary host as the checksum source means an attacker who can influence the environment for `hsh login`/`hsh setup` can point the flow at a malicious repo mirror and still satisfy checksum verification.

### Fix Focus Areas
- src/tunnel/installer.ts[64-82]
- src/tunnel/installer.ts[190-203]
- src/tunnel/installer.ts[231-240]

### Implementation guidance
Pick one of these (strongest first):
1. Remove the override entirely in production builds (keep a test-only injection via dependency injection or a test helper).
2. Require an explicit opt-in env flag like `HSH_UNSAFE_ALLOW_DAEMON_RELEASE_BASE=1` before honoring `HSH_DAEMON_RELEASE_BASE`.
3. Restrict the override to `https://github.com`-like origins (or an allowlist), and reject non-HTTPS URLs.

Add a unit test asserting the override is ignored/rejected unless explicitly enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Env override masks missing daemon 🐞 Bug ≡ Correctness
Description
daemonInstalled() returns true when resolveTokenPath().fromEnv is true even if the token file does
not exist, which can cause hsh login implicit setup (and hsh setup without --force) to skip
installation on machines where the daemon is actually absent but env vars are set.
Code

src/commands/setup.ts[R39-42]

+export function daemonInstalled(): boolean {
+  if (readControlToken()) return true;
+  const tok = resolveTokenPath();
+  return tok.exists || tok.fromEnv;
Evidence
daemonInstalled() returns true on tok.fromEnv, and resolveTokenPath() sets fromEnv: true
whenever the env override exists, independently of whether the token file exists. loginCommand uses
daemonInstalled() to decide whether to run implicit setup.

src/commands/setup.ts[39-43]
src/tunnel/socket-path.ts[90-108]
src/commands/login.ts[79-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`daemonInstalled()` currently treats `resolveTokenPath().fromEnv` as proof the daemon is installed. But `fromEnv` only means an env var was set (e.g., `HSH_TUNNELD_TOKEN_FILE`), not that the file exists.

This can incorrectly suppress:
- the implicit install path in `hsh login` (setupWanted becomes false), and
- `hsh setup` early-exit messaging (claims already installed),
when the env var points to a missing file.

### Issue Context
`resolveTokenPath()` sets `fromEnv: true` regardless of existence when an override is present.

### Fix Focus Areas
- src/commands/setup.ts[39-43]
- src/tunnel/socket-path.ts[90-108]
- src/commands/login.ts[79-88]

### Implementation guidance
1. Change `daemonInstalled()` to only return true on *actual presence* indicators:
  - `readControlToken()` OR `resolveTokenPath().exists` (and optionally `resolveSocketPath().exists`).
2. If you still need “don’t implicitly install when user has env overrides”, introduce a separate predicate like `daemonEnvOverridesPresent()` and use it only to gate the *implicit* setup path (not the explicit `hsh setup` command).
3. Add tests for:
  - `HSH_TUNNELD_TOKEN_FILE` set to a nonexistent path → `daemonInstalled()` should be false; implicit setup should still be allowed (or should print a clear hint, depending on desired behavior).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 15 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/commands/setup.ts
Comment on lines +119 to +120
export const setupCommand = new Command("setup")
.description("Install the Hoop networking components (hsh-tunneld system service)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. setup.ts missing command export 📘 Rule violation ≡ Correctness

The new command file exports setupCommand but does not export a Command object as required,
making command exports inconsistent across src/commands. This can break conventions/tools that
rely on a standard Command export name.
Agent Prompt
## Issue description
Compliance rule requires each file under `src/commands/` to define and export a value named `Command`. The new `src/commands/setup.ts` instead exports `setupCommand`.

## Issue Context
`src/index.ts` currently imports `setupCommand` from `./commands/setup.ts`.

## Fix Focus Areas
- src/commands/setup.ts[119-125]
- src/index.ts[4-4]
- src/index.ts[27-29]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/tunnel/installer.ts
Comment on lines +383 to +386
progress(`Verified ${downloaded.assetName} (sha256 ${downloaded.sha256})`);
progress("Registering the daemon with your system service manager (sudo required)");
runPrivilegedInstall(downloaded.path, opts.installArgs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Toctou root binary exec 🐞 Bug ⛨ Security

installDaemon downloads hsh-tunneld into a user-writable hsh state dir and then executes that staged
path via sudo; another process running as the same user can swap the file between verification and
sudo exec, leading to arbitrary code execution as root when the user enters their password.
Agent Prompt
### Issue description
`hsh setup` verifies the downloaded daemon while writing it to `~/.hsh/bin/hsh-tunneld`, but later runs that *path* as root (`sudo <path> install`). Because the staged file is user-writable, another process running as the same user can replace it after verification (including while the user is typing their sudo password). That creates a TOCTOU window where unverified bytes may execute as root.

### Issue Context
- The staging location is inside the user’s hsh home directory.
- The privileged step executes the staged file directly via `sudo`.

### Fix Focus Areas
- src/tunnel/installer.ts[161-165]
- src/tunnel/installer.ts[280-295]
- src/tunnel/installer.ts[383-386]

### Implementation guidance
Implement a privileged “verify-then-exec” step so the hash check is performed *inside* the sudo session on a root-owned (non-user-modifiable) file:
1. Change `downloadDaemon()` (or `installDaemon()`) to return the expected SHA as well (or compute it again).
2. In `runPrivilegedInstall`, when not already root:
   - Run a single `sudo` command that:
     - `chown root:root` + `chmod 0755` the staged file **first** (so the user can’t modify it anymore),
     - recomputes and checks sha256 against the expected value (use `sha256sum` on Linux and `shasum -a 256` on macOS, or detect availability),
     - only then `exec`s `"$daemonPath" install ...`.
3. If the checksum verification fails inside sudo, abort and delete the staged file.

This closes the TOCTOU window without requiring changes to the Go daemon installer logic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/tunnel/installer.ts
Comment on lines +76 to +81
function downloadBase(): string {
const override = process.env.HSH_DAEMON_RELEASE_BASE;
if (override && override.trim() !== "") {
return override.trim().replace(/\/+$/, "");
}
return "https://github.com";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Untrusted daemon source override 🐞 Bug ⛨ Security

HSH_DAEMON_RELEASE_BASE lets callers redirect both the manifest and daemon download to an arbitrary
base URL, so an attacker who can influence the environment can change the trust root and cause
installation of a root-executed binary from a non-GitHub host.
Agent Prompt
### Issue description
`downloadBase()` honors `HSH_DAEMON_RELEASE_BASE` and uses it for both `SHA256SUMS` and the daemon asset. This effectively makes the trust root configurable at runtime for code that will be executed as root.

### Issue Context
Even if the checksum is verified, allowing an arbitrary host as the checksum source means an attacker who can influence the environment for `hsh login`/`hsh setup` can point the flow at a malicious repo mirror and still satisfy checksum verification.

### Fix Focus Areas
- src/tunnel/installer.ts[64-82]
- src/tunnel/installer.ts[190-203]
- src/tunnel/installer.ts[231-240]

### Implementation guidance
Pick one of these (strongest first):
1. Remove the override entirely in production builds (keep a test-only injection via dependency injection or a test helper).
2. Require an explicit opt-in env flag like `HSH_UNSAFE_ALLOW_DAEMON_RELEASE_BASE=1` before honoring `HSH_DAEMON_RELEASE_BASE`.
3. Restrict the override to `https://github.com`-like origins (or an allowlist), and reject non-HTTPS URLs.

Add a unit test asserting the override is ignored/rejected unless explicitly enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/commands/setup.ts
Comment on lines +39 to +42
export function daemonInstalled(): boolean {
if (readControlToken()) return true;
const tok = resolveTokenPath();
return tok.exists || tok.fromEnv;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Env override masks missing daemon 🐞 Bug ≡ Correctness

daemonInstalled() returns true when resolveTokenPath().fromEnv is true even if the token file does
not exist, which can cause hsh login implicit setup (and hsh setup without --force) to skip
installation on machines where the daemon is actually absent but env vars are set.
Agent Prompt
### Issue description
`daemonInstalled()` currently treats `resolveTokenPath().fromEnv` as proof the daemon is installed. But `fromEnv` only means an env var was set (e.g., `HSH_TUNNELD_TOKEN_FILE`), not that the file exists.

This can incorrectly suppress:
- the implicit install path in `hsh login` (setupWanted becomes false), and
- `hsh setup` early-exit messaging (claims already installed),
when the env var points to a missing file.

### Issue Context
`resolveTokenPath()` sets `fromEnv: true` regardless of existence when an override is present.

### Fix Focus Areas
- src/commands/setup.ts[39-43]
- src/tunnel/socket-path.ts[90-108]
- src/commands/login.ts[79-88]

### Implementation guidance
1. Change `daemonInstalled()` to only return true on *actual presence* indicators:
   - `readControlToken()` OR `resolveTokenPath().exists` (and optionally `resolveSocketPath().exists`).
2. If you still need “don’t implicitly install when user has env overrides”, introduce a separate predicate like `daemonEnvOverridesPresent()` and use it only to gate the *implicit* setup path (not the explicit `hsh setup` command).
3. Add tests for:
   - `HSH_TUNNELD_TOKEN_FILE` set to a nonexistent path → `daemonInstalled()` should be false; implicit setup should still be allowed (or should print a clear hint, depending on desired behavior).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Qodo Fixer

🍒 Ready to be cherry-picked — ✅ Merged (0) · ☑ Fixed (1)

Grey Divider

🔗 Fix PR: #35

This fix PR was closed automatically. Its branch is preserved so you can cherry pick the changes into the original PR.

Prompt for coding agent

This is an automated fix prepared on a separate branch (#35). It is NOT applied to this PR.
To use it: review Fix PR #35 (https://github.com/hoophq/hsh/pull/35), evaluate each change critically against your local context, and cherry-pick the changes that are correct into this branch. Do not accept them blindly.
Process — 1 fixed
  • ☑ Fixed: TOCTOU root binary exec

@racerxdl
racerxdl merged commit 1fad566 into main Sep 4, 2026
3 checks passed
@racerxdl
racerxdl deleted the dep-141 branch September 4, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant