Skip to content

feat(ssh): real-SSH access via in-VM sshd + gvproxy port forwarding#541

Closed
nieyy wants to merge 1 commit into
boxlite-ai:mainfrom
nieyy:claude/orchestrator/c944e67f
Closed

feat(ssh): real-SSH access via in-VM sshd + gvproxy port forwarding#541
nieyy wants to merge 1 commit into
boxlite-ai:mainfrom
nieyy:claude/orchestrator/c944e67f

Conversation

@nieyy

@nieyy nieyy commented May 16, 2026

Copy link
Copy Markdown

Adds full SSH (SFTP, scp, port forwarding, non-PTY exec) to BoxLite sandboxes by routing SSH-access tokens through a per-sandbox port on the Runner EC2 directly to a real sshd running inside the container namespace, bypassing the Rust exec bridge that blocks binary streams. The external SSH Gateway detects real-SSH tokens (unix_user non-null) and TCP-proxies to the allocated port; legacy exec-bridge tokens fall through unchanged.

Security contract:

  • Only the gateway public key is installed in guest sshd authorized_keys
  • Real-SSH tokens require unix_user; empty/absent → null (exec-bridge) at controller boundary
  • Gateway is fail-closed on every error path: lookup failure, degraded state, unix_user mismatch, missing RUNNER_API_TOKEN, empty unix_user string
  • Per-sandbox Redis lock (90s TTL, compare-and-delete) guards concurrent enable/disable
  • Expired row cleanup covers all three cases: runner-attached, runner-less, null sandbox
  • Non-expired token with null sandbox (concurrent delete) returns invalid cleanly
  • Revocation is fail-closed (disable-before-delete): runner unreachable → error propagated, DB row preserved for retry; legacy exec-bridge tokens use best-effort runner cleanup
  • PermitRootLogin set to prohibit-password; default SSH user is root

Implementation:

  • CI statically compiles OpenSSH 9.7p1 (sshd + ssh-keygen) via Alpine musl Docker;
    guest.rs injects binaries + management scripts into the guest ext4 rootfs at build time
  • SSH infra files (sshd, ssh-keygen, management scripts) bind-mounted read-only into
    container from guest rootfs, gated on /boxlite/bin/sshd presence with per-file checks
  • gvproxy admin HTTP server on gvproxy-admin.sock handles /services/forwarder/expose
    for dynamic per-sandbox port forwarding to guest sshd (port 22222)
  • boxlite-enable-ssh: UID collision check covers /etc/group; sudoers written before sshd
    starts; passwordless sudo scoped to default 'boxlite' user only; root home dir handled
  • boxlite-disable-ssh: mirrors enable-side sudoers guard on disable
  • sst.config.ts: auto-derive SSH_GATEWAY_PUBLIC_KEY from SSH_PRIVATE_KEY_B64; quote key
    in systemd Environment= (spaces); restrict port 22100–22199 ingress to ECS gateway SG
  • runner-update-binary.sh: guard SSH_GATEWAY_PUBLIC_KEY injection so running without the
    secret does not erase the live value
  • runnerAdapter.v2.ts: implement enableSSHAccess / disableSSHAccess natively;
    503 surfaces as error, 404 is no-op on disable; remove stale apiVersion gate
  • sandbox.service.ts: fix remainingRealSsh to count only unixUser≠null rows; add
    lock-lost fencing before runner disable; rollback on create failure skips if prior
    real-SSH session existed; real→legacy rotation calls disableSSHAccess fail-closed
  • client_ssh.go: internalBoxID() helper with three-level fallback (stored state, runtime
    query, external UUID) for correct gvproxy socket path resolution; hostPort=0 case
    handled in forward recovery (degraded mode when gvproxy was unavailable at enable time)
  • ssh-gateway: CloseWrite() after each io.Copy direction so scp and commands waiting for
    server EOF exit cleanly instead of hanging
  • 60 regression tests covering all fixed invariants

Summary by CodeRabbit

  • New Features

    • Added box-level SSH access management, including creating, checking, and revoking access with optional per-user SSH sessions.
    • Introduced support for real SSH connections alongside the existing terminal flow, with better handling for interactive sessions and command execution.
    • Added a build target for producing static SSH server binaries used by the platform.
  • Bug Fixes

    • Improved reliability of SSH access validation, routing, and session handling.
    • Tightened runtime checks so SSH-related features fail safely when required keys or services are unavailable.

@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 2 times, most recently from 9c6c763 to 74f929f Compare May 17, 2026 19:28
@nieyy nieyy changed the title [WIP] feat(ssh): real-SSH access for sandboxes via in-VM sshd + gvproxy por… feat(ssh): real-SSH access for sandboxes via in-VM sshd + gvproxy port forwarding May 17, 2026
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 74f929f to 6504ffd Compare May 17, 2026 19:56

@law-chain-hot law-chain-hot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Left one comment on the OpenSSH download step.

Comment thread .github/workflows/build-runtime.yml Outdated
@lilongen

Copy link
Copy Markdown
Contributor

Code review

Found 4 issues:

  1. boxlite-disable-ssh hard-codes HOME_DIR="/home/$UNIX_USER" while boxlite-enable-ssh branches on root. When SSH was enabled for root (the controller default), enable writes the marker to /root/.ssh/.ssh_enabled, disable then deletes a non-existent /home/root/.ssh/.ssh_enabled (no-op), and the surviving marker causes boxlite-ensure-ssh to silently re-enable sshd on the next VM restart — defeating the API-level revoke.

UNIX_USER="${1:?Usage: boxlite-disable-ssh <unix_user>}"
HOME_DIR="/home/$UNIX_USER"
SSH_DIR="$HOME_DIR/.ssh"
PID_FILE="/tmp/boxlite-sshd.pid"

Compare with the enable-side branch:

UNIX_USER="${1:?Usage: boxlite-enable-ssh <unix_user>}"
if [ "$UNIX_USER" = "root" ]; then
HOME_DIR="/root"
else
HOME_DIR="/home/$UNIX_USER"
fi
SSH_DIR="$HOME_DIR/.ssh"

  1. sshUserOrDefault() doc says it returns \"boxlite\" and the surrounding comment claims it exists to prevent inheriting root from the image, but the implementation returns \"root\". The struct field doc (// Empty means \"boxlite\") and the test name TestSSHUserDefaultsToBoxlite confirm the intended default. CLAUDE.md: "Update nearby comments when behavior changes."

// sshUserOrDefault returns sshUser if set, otherwise "boxlite".
// The SSH gateway always selects an explicit user to prevent inheriting
// the image-default user (often root for standard images like python:slim
// or alpine), which would silently grant root access to SSH sessions.
func (s *Service) sshUserOrDefault() string {
if s.sshUser != "" {
return s.sshUser
}
return "root"
}

  1. Per-box mutex entries in boxSSHMu are inserted on first EnableSSHAccess but never deleted. cleanupSSHOnDestroy removes sshStates and sshBoxes entries but leaves boxSSHMu to grow without bound across the runner process lifetime. CLAUDE.md: "No unbounded queues/concurrency/memory."

func (c *Client) cleanupSSHOnDestroy(ctx context.Context, boxId string, alloc *sshport.Allocator) {
mu := c.boxSSHMutex(boxId)
mu.Lock()
defer mu.Unlock()
c.mu.RLock()
state, ok := c.sshStates[boxId]
c.mu.RUnlock()
if !ok {
return
}
// Best-effort unexpose; the VM is gone so the port forward is already
// dead, but clean up gvproxy state if the admin socket is still present.
adminSock := gvproxyAdminSocket(c.homeDir, c.internalBoxID(ctx, boxId, state))
_ = removeGvproxyPortForward(ctx, adminSock, state.HostPort)
c.mu.Lock()
delete(c.sshStates, boxId)
delete(c.sshBoxes, boxId)
c.mu.Unlock()
c.removeSSHStateFile(boxId)
if alloc != nil {
alloc.Release(boxId)
}
}

  1. New log line prints the raw SSH bearer token in cleartext on the fail-closed path. The token is a live revocable credential. CLAUDE.md: "Mask secrets in errors and logs." Consider redacting (prefix + ) consistently with how other credentials are handled.

// The API contract guarantees this, but enforce it here so any schema drift
// or future API regression fails fast rather than producing a silent empty-sandboxId call.
if validation.SandboxId == "" {
log.Warnf("API returned valid=true with empty sandboxId for token %s — rejecting (fail-closed)", token)
conn.Close()
return
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@lilongen lilongen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See detailed review: #541 (comment)

Blocking on issue #1 (boxlite-disable-ssh root-home asymmetry) — when the controller's default unix_user=root is used, disable-ssh deletes a non-existent path and the surviving /root/.ssh/.ssh_enabled marker causes boxlite-ensure-ssh to silently re-enable sshd on the next VM restart, defeating the API-level revoke. This is a security-relevant revoke bypass on the default code path.

Issues #2 (sshUserOrDefault comment/impl mismatch), #3 (boxSSHMu unbounded growth — CLAUDE.md "No unbounded queues/concurrency/memory"), and #4 (raw SSH bearer token logged in cleartext — CLAUDE.md "Mask secrets in errors and logs") should also be addressed before merge.

🤖 Generated with Claude Code

Comment thread src/deps/libgvproxy-sys/gvproxy-bridge/main.go Outdated
@DorianZheng

Copy link
Copy Markdown
Member

Why does the runner compute the gvproxy admin socket path itself?

apps/runner/pkg/boxlite/client_ssh.go:

filepath.Join(homeDir, "boxes", boxId, "sockets", "gvproxy-admin.sock")

This makes ~/boxes/<id>/sockets/ an implicit contract between the runner and boxlite — if the layout ever changes (or the id space; cf. the three-level fallback in internalBoxID), the runner breaks at runtime with no compile-time signal. Is this coupling intentional, or should the path come from boxlite instead?

@DorianZheng

Copy link
Copy Markdown
Member

Local builds silently skip sshd injection, so real-SSH can't be exercised end-to-end on dev machines.

The static boxlite-sshd is only produced by the new CI step in .github/workflows/build-runtime.yml (Alpine Docker → dist/boxlite-sshd). On a developer's machine there's no equivalent make target, so each layer falls back to a warn-and-skip:

  • src/boxlite/build.rs::find_prebuilt_sshd returns Nonecopy_prebuilt_binary warns and skips.
  • src/boxlite/src/rootfs/guest.rs::build_and_install logs boxlite-sshd not found, SSH access will be unavailable and proceeds.
  • src/guest/src/container/spec.rs short-circuits the bind mounts on !Path::new("/boxlite/bin/sshd").exists().

Each layer fails open, so the gap doesn't surface until someone enables SSH on a locally-built sandbox and gets enable_ssh script exited 127. Would it make sense to add a make sshd target (extracting the workflow's Alpine-Docker step into a shared scripts/build-static-sshd.sh) so make runtime-debug can produce a fully SSH-capable guest locally?

@DorianZheng

Copy link
Copy Markdown
Member

should update box.openapi.yaml if we modify SDK API

@nieyy

nieyy commented May 20, 2026

Copy link
Copy Markdown
Author

Code review

Found 4 issues:

  1. boxlite-disable-ssh hard-codes HOME_DIR="/home/$UNIX_USER" while boxlite-enable-ssh branches on root. When SSH was enabled for root (the controller default), enable writes the marker to /root/.ssh/.ssh_enabled, disable then deletes a non-existent /home/root/.ssh/.ssh_enabled (no-op), and the surviving marker causes boxlite-ensure-ssh to silently re-enable sshd on the next VM restart — defeating the API-level revoke.

UNIX_USER="${1:?Usage: boxlite-disable-ssh <unix_user>}"
HOME_DIR="/home/$UNIX_USER"
SSH_DIR="$HOME_DIR/.ssh"
PID_FILE="/tmp/boxlite-sshd.pid"

Compare with the enable-side branch:

UNIX_USER="${1:?Usage: boxlite-enable-ssh <unix_user>}"
if [ "$UNIX_USER" = "root" ]; then
HOME_DIR="/root"
else
HOME_DIR="/home/$UNIX_USER"
fi
SSH_DIR="$HOME_DIR/.ssh"

  1. sshUserOrDefault() doc says it returns \"boxlite\" and the surrounding comment claims it exists to prevent inheriting root from the image, but the implementation returns \"root\". The struct field doc (// Empty means \"boxlite\") and the test name TestSSHUserDefaultsToBoxlite confirm the intended default. CLAUDE.md: "Update nearby comments when behavior changes."

// sshUserOrDefault returns sshUser if set, otherwise "boxlite".
// The SSH gateway always selects an explicit user to prevent inheriting
// the image-default user (often root for standard images like python:slim
// or alpine), which would silently grant root access to SSH sessions.
func (s *Service) sshUserOrDefault() string {
if s.sshUser != "" {
return s.sshUser
}
return "root"
}

  1. Per-box mutex entries in boxSSHMu are inserted on first EnableSSHAccess but never deleted. cleanupSSHOnDestroy removes sshStates and sshBoxes entries but leaves boxSSHMu to grow without bound across the runner process lifetime. CLAUDE.md: "No unbounded queues/concurrency/memory."

func (c *Client) cleanupSSHOnDestroy(ctx context.Context, boxId string, alloc *sshport.Allocator) {
mu := c.boxSSHMutex(boxId)
mu.Lock()
defer mu.Unlock()
c.mu.RLock()
state, ok := c.sshStates[boxId]
c.mu.RUnlock()
if !ok {
return
}
// Best-effort unexpose; the VM is gone so the port forward is already
// dead, but clean up gvproxy state if the admin socket is still present.
adminSock := gvproxyAdminSocket(c.homeDir, c.internalBoxID(ctx, boxId, state))
_ = removeGvproxyPortForward(ctx, adminSock, state.HostPort)
c.mu.Lock()
delete(c.sshStates, boxId)
delete(c.sshBoxes, boxId)
c.mu.Unlock()
c.removeSSHStateFile(boxId)
if alloc != nil {
alloc.Release(boxId)
}
}

  1. New log line prints the raw SSH bearer token in cleartext on the fail-closed path. The token is a live revocable credential. CLAUDE.md: "Mask secrets in errors and logs." Consider redacting (prefix + ) consistently with how other credentials are handled.

// The API contract guarantees this, but enforce it here so any schema drift
// or future API regression fails fast rather than producing a silent empty-sandboxId call.
if validation.SandboxId == "" {
log.Warnf("API returned valid=true with empty sandboxId for token %s — rejecting (fail-closed)", token)
conn.Close()
return
}

🤖 Generated with Claude Code

  • If this code review was useful, please react with 👍. Otherwise, react with 👎.

Fixed all 4, thanks for the thorough review:

boxlite-disable-ssh root-home asymmetry — added the root branch so disable targets /root/.ssh/ consistently with enable. Revoke now works on the default code path.
sshUserOrDefault comment/test mismatch — updated the doc comment and renamed TestSSHUserDefaultsToBoxlite → TestSSHUserDefaultsToRoot, both now match the "root" implementation.
boxSSHMu unbounded growth — added delete(c.boxSSHMu, boxId) in cleanupSSHOnDestroy after releasing the mutex.
Raw token in logs — added redactToken() (keeps 8-char prefix + …), applied to both log sites.

@nieyy

nieyy commented May 20, 2026

Copy link
Copy Markdown
Author

Local builds silently skip sshd injection, so real-SSH can't be exercised end-to-end on dev machines.

The static boxlite-sshd is only produced by the new CI step in .github/workflows/build-runtime.yml (Alpine Docker → dist/boxlite-sshd). On a developer's machine there's no equivalent make target, so each layer falls back to a warn-and-skip:

  • src/boxlite/build.rs::find_prebuilt_sshd returns Nonecopy_prebuilt_binary warns and skips.
  • src/boxlite/src/rootfs/guest.rs::build_and_install logs boxlite-sshd not found, SSH access will be unavailable and proceeds.
  • src/guest/src/container/spec.rs short-circuits the bind mounts on !Path::new("/boxlite/bin/sshd").exists().

Each layer fails open, so the gap doesn't surface until someone enables SSH on a locally-built sandbox and gets enable_ssh script exited 127. Would it make sense to add a make sshd target (extracting the workflow's Alpine-Docker step into a shared scripts/build-static-sshd.sh) so make runtime-debug can produce a fully SSH-capable guest locally?

Good suggestion — added make sshd that calls scripts/build/build-static-sshd.sh (requires Docker). CI now uses the same script so the build logic isn't duplicated. Devs can run make sshd to get a fully SSH-capable guest locally.

Added make sshd target that builds static boxlite-sshd / boxlite-ssh-keygen via Alpine Docker (same as CI). Tested on the dev machine — output is statically linked OpenSSH 9.7p1. Requires Docker Desktop locally.

@nieyy

nieyy commented May 20, 2026

Copy link
Copy Markdown
Author

should update box.openapi.yaml if we modify SDK API

Thanks — added the SSH endpoints (createSshAccess, revokeSshAccess, validateSshAccess) and their schemas (SshAccess, SshAccessValidationResult, CreateSshAccessRequest) to box.openapi.yaml.

@nieyy

nieyy commented May 20, 2026

Copy link
Copy Markdown
Author

Why does the runner compute the gvproxy admin socket path itself?

apps/runner/pkg/boxlite/client_ssh.go:

filepath.Join(homeDir, "boxes", boxId, "sockets", "gvproxy-admin.sock")

This makes ~/boxes/<id>/sockets/ an implicit contract between the runner and boxlite — if the layout ever changes (or the id space; cf. the three-level fallback in internalBoxID), the runner breaks at runtime with no compile-time signal. Is this coupling intentional, or should the path come from boxlite instead?

Added boxlite_box_admin_sock_path(CBoxHandle*) to the C SDK — it constructs the path from the home_dir already stored in RuntimeHandle at runtime creation time. The Go SDK exposes this as (*Box).AdminSockPath().

EnableSSHAccess now calls bx.AdminSockPath() on first use and stores the result in SSHState.AdminSockPath (persisted to ssh-state.json). Subsequent SSH operations (Disable, Reapply, Cleanup) retrieve it from state via adminSockForState(), which falls back to the old local derivation only for state files written before this change.

One related note for the future: if a VM is ever migrated to a different runner machine, the gvproxy port forward will be lost along with the socket. EnableSSHAccess would need to be re-called after migration to re-establish the forward and refresh the stored path. Worth keeping in mind when designing the migration path.

@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 2 times, most recently from eaf0ff8 to 5e40c87 Compare May 20, 2026 18:09
@DorianZheng

Copy link
Copy Markdown
Member

Why does the runner compute the gvproxy admin socket path itself?
apps/runner/pkg/boxlite/client_ssh.go:

filepath.Join(homeDir, "boxes", boxId, "sockets", "gvproxy-admin.sock")

This makes ~/boxes/<id>/sockets/ an implicit contract between the runner and boxlite — if the layout ever changes (or the id space; cf. the three-level fallback in internalBoxID), the runner breaks at runtime with no compile-time signal. Is this coupling intentional, or should the path come from boxlite instead?

Added boxlite_box_admin_sock_path(CBoxHandle*) to the C SDK — it constructs the path from the home_dir already stored in RuntimeHandle at runtime creation time. The Go SDK exposes this as (*Box).AdminSockPath().

EnableSSHAccess now calls bx.AdminSockPath() on first use and stores the result in SSHState.AdminSockPath (persisted to ssh-state.json). Subsequent SSH operations (Disable, Reapply, Cleanup) retrieve it from state via adminSockForState(), which falls back to the old local derivation only for state files written before this change.

One related note for the future: if a VM is ever migrated to a different runner machine, the gvproxy port forward will be lost along with the socket. EnableSSHAccess would need to be re-called after migration to re-establish the forward and refresh the stored path. Worth keeping in mind when designing the migration path.

I think it's too broad to expose the admin socket. The user will not know how to use it. A set of abstraction APIs is required

@nieyy

nieyy commented May 21, 2026

Copy link
Copy Markdown
Author

Why does the runner compute the gvproxy admin socket path itself?
apps/runner/pkg/boxlite/client_ssh.go:

filepath.Join(homeDir, "boxes", boxId, "sockets", "gvproxy-admin.sock")

This makes ~/boxes/<id>/sockets/ an implicit contract between the runner and boxlite — if the layout ever changes (or the id space; cf. the three-level fallback in internalBoxID), the runner breaks at runtime with no compile-time signal. Is this coupling intentional, or should the path come from boxlite instead?

Added boxlite_box_admin_sock_path(CBoxHandle*) to the C SDK — it constructs the path from the home_dir already stored in RuntimeHandle at runtime creation time. The Go SDK exposes this as (*Box).AdminSockPath().
EnableSSHAccess now calls bx.AdminSockPath() on first use and stores the result in SSHState.AdminSockPath (persisted to ssh-state.json). Subsequent SSH operations (Disable, Reapply, Cleanup) retrieve it from state via adminSockForState(), which falls back to the old local derivation only for state files written before this change.
One related note for the future: if a VM is ever migrated to a different runner machine, the gvproxy port forward will be lost along with the socket. EnableSSHAccess would need to be re-called after migration to re-establish the forward and refresh the stored path. Worth keeping in mind when designing the migration path.

I think it's too broad to expose the admin socket. The user will not know how to use it. A set of abstraction APIs is required

Moved boxlite_box_admin_sock_path to boxlite_internal.h — it's now only included by first-party consumers (the Go SDK's bridge.h), not part of the public boxlite.h surface. The pattern is similar to MySQL's internal header split.

@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 4 times, most recently from 18b493d to 2e8bc74 Compare May 26, 2026 01:43
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 2 times, most recently from b926046 to 7197edc Compare June 1, 2026 18:10
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 7197edc to 095df7c Compare June 5, 2026 21:33
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces real-SSH access for runner sandboxes end-to-end: API/DTO/migration contracts for a nullable unixUser, a per-runner host port allocator, boxlite client SSH lifecycle management with gvproxy port forwarding, runner HTTP controllers, an SSH gateway rewritten to proxy PTY sessions with fail-closed token routing, guest-side enable/disable scripts, gvproxy admin socket wiring, and static sshd build/deploy tooling. It also includes unrelated audit target-id string-cast cleanup and Jest/tsconfig/workspace config housekeeping.

Changes

Real SSH Access Feature

Layer / File(s) Summary
API contracts, DTOs, migration, and service wiring
openapi/box.openapi.yaml, apps/api-client-go/*, apps/api/src/box/dto/ssh-access.dto.ts, apps/api/src/box/entities/ssh-access.entity.ts, apps/api/src/migrations/1778751201300-migration.ts, apps/api/src/box/controllers/box.controller.ts, apps/api/src/box/services/box.service.ts, apps/api/src/box/common/redis-lock.provider.ts, apps/api/src/box/runner-adapter/*, apps/libs/api-client/src/*, apps/dashboard/...
Adds unixUser to SSH access DTOs/entity/openapi schemas, an owning Redis lock for coordinated enable/rotate/revoke, per-box real-SSH enable/disable via runner adapters, and controller/service logic distinguishing legacy vs real-SSH tokens.
SSH host port allocator
apps/runner/pkg/sshport/allocator.go, allocator_test.go
Adds idempotent Allocate/Release/GetPort/ReservePort for a fixed port pool, with conflict/leak tests.
Runner config and allocator wiring
apps/runner/cmd/runner/config/config.go, main.go, pkg/runner/runner.go
Adds SSH gateway public key/port config and shares one allocator instance across the boxlite client and runner instance.
Boxlite client SSH state management
apps/runner/pkg/boxlite/client.go, client_ssh.go, exec_manager.go
Persists per-box SSH state, reapplies gvproxy port forwards on start/restart, cleans up on destroy, extends StartExecution with env/user/onExit, and adds an early-exit check in Signal.
Runner HTTP controllers for SSH access
apps/runner/pkg/api/controllers/ssh_access.go, ssh_access_test.go, server.go
Adds Enable/Get/Disable SSH access endpoints with unix_user validation, gateway-only key installation, and degraded-state reporting.
SSH gateway PTY proxy service
apps/runner/pkg/sshgateway/service.go, tests, README.md
Rewrites the gateway to route only PTY session channels into BoxLite exec, rejecting non-PTY/subsystem requests, with signal forwarding and drain-safe shutdown.
SSH gateway token routing
apps/ssh-gateway/main.go, main_test.go
Adds fail-closed routing between real-SSH and exec-bridge based on runner state and token unix_user match, plus bidirectional half-close forwarding.
Guest SSH scripts and rootfs injection
src/guest/scripts/boxlite-enable-ssh, boxlite-disable-ssh, boxlite-ensure-ssh, src/guest/src/container/spec.rs, src/boxlite/src/rootfs/guest.rs
Adds guest scripts to enable/disable/ensure sshd and injects sshd/ssh-keygen binaries plus scripts into the guest rootfs and container mounts.
Gvproxy admin socket and SDK plumbing
src/boxlite/src/net/gvproxy/config.rs, src/boxlite/src/runtime/layout.rs, src/deps/libgvproxy-sys/..., sdks/c/*, sdks/go/*
Adds a gvproxy admin Unix socket, per-box path helper, C/Go FFI AdminSockPath, home_dir plumbing, and Go SDK exec OnExit/User options.
Static sshd build and deploy tooling
scripts/build/build-static-sshd.sh, make/build.mk, make/test.mk, .github/workflows/build-runtime.yml, src/boxlite/build.rs, apps/infra/sst.config.ts, scripts/deploy/runner-update-binary.sh
Adds a Docker-based static sshd build script, Make/CI targets, embedded prebuilt binary discovery, and infra/deploy wiring for the SSH gateway public key and runner ingress ports.

Audit target-id string cast cleanup

Layer / File(s) Summary
Explicit string casts in @Audit targetIdFromRequest
multiple NestJS controllers and organization-access.guard.ts
Casts route params to string in numerous @Audit targetIdFromRequest callbacks with no behavioral change.

Workspace and Jest config housekeeping

Layer / File(s) Summary
Jest/tsconfig aliases and gitignore
apps/api/jest.config.ts, apps/api/tsconfig.spec.json, .gitignore
Adds moduleNameMapper/paths aliases for local library packages and ignores Nx cache directories.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RunnerAPI
  participant BoxliteClient
  participant SSHPortAllocator
  participant Gvproxy
  participant GuestSSHD

  RunnerAPI->>BoxliteClient: EnableSSHAccess(boxId, gatewayKey, unixUser)
  BoxliteClient->>SSHPortAllocator: Allocate(boxId)
  BoxliteClient->>Gvproxy: addGvproxyPortForward(hostPort)
  BoxliteClient->>GuestSSHD: EnableSSH(authorizedKeys, unixUser)
  GuestSSHD-->>BoxliteClient: sshd started
  BoxliteClient-->>RunnerAPI: hostPort, unixUser, enabled=true
Loading
sequenceDiagram
  participant Client
  participant SSHGateway
  participant RunnerAPI
  participant RealSSHD
  participant ExecBridge

  Client->>SSHGateway: SSH connect with token
  SSHGateway->>SSHGateway: validate token, extract unix_user
  SSHGateway->>RunnerAPI: getRunnerSSHAccess(sandboxId)
  RunnerAPI-->>SSHGateway: enabled, degraded, hostPort, unixUser
  alt real-SSH available and matches
    SSHGateway->>RealSSHD: connectToRunner(unixUser, hostPort)
  else fail-closed or mismatch
    SSHGateway->>ExecBridge: connectToRunner(execUser, execPort)
  end
Loading

Possibly related PRs

  • boxlite-ai/boxlite#664: Both PRs modify apps/infra/sst.config.ts in the runner provisioning/user-data setup path, touching buildRunnerUserData inputs and per-runner environment/token wiring.

Suggested reviewers: DorianZheng, law-chain-hot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: real-SSH access via in-VM sshd and gvproxy forwarding.
Description check ✅ Passed The description is detailed and covers the change, but it does not follow the template headings and omits an explicit verification step.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cla-assistant

cla-assistant Bot commented Jun 5, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cla-assistant

cla-assistant Bot commented Jun 5, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (3)
apps/api/src/sandbox/dto/ssh-access.dto.ts (1)

122-140: 💤 Low value

Document field precedence for dual naming convention.

The DTO accepts both unixUser (camelCase) and unix_user (snake_case), which creates potential ambiguity. The controller resolves this with body?.unixUser?.trim() || body?.unix_user?.trim(), meaning camelCase wins if both are provided.

Consider documenting this precedence in the @ApiProperty descriptions to clarify the resolution order for API consumers.

📝 Suggested documentation improvement
   `@ApiProperty`({
-    description: 'Unix user for SSH access (camelCase form)',
+    description: 'Unix user for SSH access (camelCase form; takes precedence over unix_user if both provided)',
     example: 'boxlite',
     required: false,
   })
   unixUser?: string

   `@ApiProperty`({
-    description: 'Unix user for SSH access (snake_case wire form)',
+    description: 'Unix user for SSH access (snake_case form; for backward compatibility, use unixUser if possible)',
     example: 'boxlite',
     required: false,
   })
   // eslint-disable-next-line `@typescript-eslint/naming-convention`
   unix_user?: string
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/sandbox/dto/ssh-access.dto.ts` around lines 122 - 140, The
ApiProperty descriptions for CreateSshAccessBodyDto's unixUser and unix_user are
ambiguous about which is used when both are provided; update the description
strings for both properties to state the precedence (camelCase unixUser takes
precedence over snake_case unix_user) and optionally reference the controller
resolution used (e.g., body?.unixUser?.trim() || body?.unix_user?.trim()) so API
consumers know camelCase wins; modify the descriptions on the unixUser and
unix_user decorators accordingly.
sdks/c/src/box_handle.rs (1)

104-131: 💤 Low value

Redundant unsafe block.

The function is already declared as unsafe extern "C", so the inner unsafe block at line 112 is redundant. You can remove it and access the raw pointer directly in the function body.

♻️ Proposed simplification
 #[unsafe(no_mangle)]
 pub unsafe extern "C" fn boxlite_box_admin_sock_path(handle: *mut CBoxHandle) -> *mut c_char {
-    unsafe {
-        if handle.is_null() {
-            return ptr::null_mut();
-        }
-        let handle_ref = &*handle;
-        if handle_ref.home_dir.as_os_str().is_empty() {
-            return ptr::null_mut();
-        }
-        let path = handle_ref
-            .home_dir
-            .join("boxes")
-            .join(handle_ref.box_id.as_str())
-            .join("sockets")
-            .join("gvproxy-admin.sock");
-        match std::ffi::CString::new(path.to_string_lossy().as_ref()) {
-            Ok(s) => s.into_raw(),
-            Err(_) => ptr::null_mut(),
-        }
+    if handle.is_null() {
+        return ptr::null_mut();
+    }
+    let handle_ref = &*handle;
+    if handle_ref.home_dir.as_os_str().is_empty() {
+        return ptr::null_mut();
+    }
+    let path = handle_ref
+        .home_dir
+        .join("boxes")
+        .join(handle_ref.box_id.as_str())
+        .join("sockets")
+        .join("gvproxy-admin.sock");
+    match std::ffi::CString::new(path.to_string_lossy().as_ref()) {
+        Ok(s) => s.into_raw(),
+        Err(_) => ptr::null_mut(),
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/c/src/box_handle.rs` around lines 104 - 131, The inner redundant
"unsafe" block inside the exported function boxlite_box_admin_sock_path should
be removed since the function is already declared unsafe extern "C"; simply use
handle, dereference to &*handle (CBoxHandle) and perform the same null checks
and path construction (using handle_ref.home_dir, handle_ref.box_id) directly in
the function body, preserving the CString::new(...) match and returning
s.into_raw() or ptr::null_mut() as before.
apps/runner/pkg/api/controllers/ssh_access_test.go (1)

36-54: 💤 Low value

Test 2 validates the function signature, not runtime behavior.

The callerKeys variable is declared but never passed to gatewayOnlyKeys because the function's signature deliberately doesn't accept caller keys. The test documents the security invariant but the assertion at lines 47-53 is redundant — if gatewayOnlyKeys only takes gwKey and returns []string{gwKey}, caller keys can never appear in the result by construction.

Consider simplifying to just verify the return value equals []string{gwKey}, which already proves caller keys cannot be injected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/runner/pkg/api/controllers/ssh_access_test.go` around lines 36 - 54,
TestGatewayOnlyKeysDoesNotIncludeCallerKey currently declares callerKeys but
never passes them to gatewayOnlyKeys, so the loop asserting they aren't present
is redundant; change the test to simply assert that gatewayOnlyKeys(gwKey)
returns exactly []string{gwKey} (i.e., compare length and element or use
reflect.DeepEqual) and remove the unused callerKeys variable and the nested
loop, keeping the test focused on the gatewayOnlyKeys function's return value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/migrations/1778751201300-migration.ts`:
- Around line 18-19: Update the comment that describes NULL semantics for the
unixUser column in the migration to reflect the current legacy-token behavior
(not "'boxlite'"): find the comment adjacent to the unixUser definition in the
migration (symbol: unixUser) and replace the misleading text so it clearly
states that existing NULL rows represent the legacy-token/previous behavior for
backward compatibility; keep note that NULL has no default and preserves
existing rows as legacy-token semantics for application logic and operational
clarity.

In `@apps/api/src/sandbox/common/redis-lock.provider.ts`:
- Around line 56-62: The waitForLockOwned loop currently can hang indefinitely;
update the waitForLockOwned method to accept an optional timeoutMs (or use a
configurable default), track elapsed time while polling lock(key, ttl, code) at
the current interval, and throw a clear error (e.g., LockAcquisitionTimeout) if
elapsed time exceeds timeoutMs; ensure you still return the LockCode on success,
keep the existing sleep interval (or make it configurable), and update any
callers of waitForLockOwned to pass the timeout or rely on the new default.

In `@apps/api/src/sandbox/services/sandbox.service.ts`:
- Around line 2317-2341: If revokedIsRealSsh is true but
runnerService.findOne(sandbox.runnerId) returns null, change the flow to "fail
closed" by aborting and throwing an error before any sshAccessRepository.delete
calls; specifically, inside the block handling remainingRealSsh === 0 and
redisLockProvider.isLockOwned(...), after calling runnerService.findOne(...)
check if runner is null and if revokedIsRealSsh throw or propagate an error (so
the DB token deletion is skipped), otherwise continue with adapter creation and
disableSSHAccess as currently implemented.

In `@apps/libs/api-client/src/api/sandbox-api.ts`:
- Around line 1824-1825: The public API createSshAccess now takes
createSshAccessBodyDto before options which breaks callers passing options as
the 4th arg; change the parameter handling to detect whether the 4th argument is
an Axios options object (implement a looksLikeAxiosOptions type-guard checking
for headers/timeout/params/baseURL) and then set body = undefined/requestOptions
= createSshAccessBodyDtoOrOptions or body =
createSshAccessBodyDtoOrOptions/requestOptions = options accordingly, then pass
the derived body and requestOptions into
localVarAxiosParamCreator.createSshAccess; apply the same backward-compatible
detection/fix to the other occurrences of this pattern in the file for the
related createSshAccess overloads.

In `@apps/runner/cmd/runner/config/config.go`:
- Around line 68-70: Validate SSH port settings immediately after environment
parsing: check SSHPortBase is within valid TCP port range (e.g., 1024–65535),
SSHPortPoolSize is >0, and that SSHPortBase+SSHPortPoolSize-1 does not exceed
65535. Perform these checks in the config parsing/constructor (where env is
decoded, e.g., the function that builds the config struct) and return a
descriptive error if any validation fails so the process fails fast; reference
the SSHPortBase and SSHPortPoolSize fields when adding the validation and error
messages.

In `@apps/runner/README.md`:
- Around line 472-475: Update the README text that currently states "Sessions
always run as the `boxlite` unix user" to reflect the implemented default SSH
user `root`; locate the sentence mentioning the SSH gateway and the `boxlite`
account (the phrase "Sessions always run as the `boxlite` unix user — the
unprivileged account...") and replace or reword it to state that real-SSH
sessions default to `root`, and keep the note that images without a `boxlite`
account remain unsupported via the SSH gateway and recommend the WebSocket
terminal or SDK exec API as alternatives.

In `@apps/ssh-gateway/main_test.go`:
- Around line 169-174: The test mutates and reads execBridgeCalled from multiple
goroutines (set in the PublicKeyCallback and read in the test), causing data
races; replace the plain bool with a concurrency-safe mechanism (e.g., an int32
using sync/atomic or protect with a sync.Mutex) and update all occurrences where
execBridgeCalled is set and checked (including the other instances noted around
lines referencing PublicKeyCallback and execBridgeCalled) to use
atomic.StoreInt32/atomic.LoadInt32 or lock/unlock so the flag is updated and
observed safely from server goroutines and the test goroutine.
- Around line 730-779: The test
TestSSHAccessTokenRejectedWhenRunnerAPITokenEmpty currently simulates routing
with local booleans; replace those asserts with behavior tests that exercise the
real routing by invoking SSHGateway.handleChannel (or the exported routing entry
used by tests) using a crafted channel/session carrying an SSH-access token and
the test signer (newTestSigner) and verifying the observed outcome (reject vs.
exec bridge) against g.runnerAPIToken == "" cases; locate the SSHGateway struct
and handleChannel method and update this test (and the similar blocks around
lines 809–865 and 867–928) to create a mock channel/connection, inject
tokenIsSSHAccess vs legacy token payloads, call the real routing function, and
assert on the actual path taken instead of local boolean math.

In `@openapi/box.openapi.yaml`:
- Around line 2140-2206: The YAML has an orphaned description under
CreateSshAccessRequest.unix_user: remove the stray `description: Opaque token
for next page` entry so the unix_user property only has its intended
nullable/description/example fields; locate the CreateSshAccessRequest schema
and delete that misplaced description line (it does not belong to `unix_user`).

In `@scripts/build/build-static-sshd.sh`:
- Around line 12-15: Add pre-flight validation at the top of the
build-static-sshd.sh script: verify Docker is available (e.g., check command -v
docker or docker --version) and exit with a clear error if not, and validate the
OUTPUT directory (the OUTPUT variable and the target created by mkdir -p
"$OUTPUT") is writable before proceeding (check directory exists and is writable
or that parent is writable), failing fast with descriptive messages if either
check fails.

In `@src/boxlite/src/rootfs/guest.rs`:
- Around line 469-474: The rootfs cache key currently omits SSH artifacts so
updates to boxlite-sshd/ssh-keygen/SSH scripts can be skipped; modify the cache
key generation to include an ssh_assets_hash() that deterministically
incorporates the contents (or a stable placeholder when missing) of binaries
discovered by util::find_binary("boxlite-sshd") and related SSH scripts/keys,
and use that hash when constructing the guest/rootfs version key (update the
code paths around the match util::find_binary(...) and the cache-key
construction spanning the blocks referenced 469 and 487-531 to include
ssh_assets_hash()); ensure ssh_assets_hash() returns deterministic values in
local-dev environments by inserting explicit placeholders for missing binaries.

In `@src/deps/libgvproxy-sys/gvproxy-bridge/main.go`:
- Around line 466-489: The admin UNIX socket listener failure is currently only
logged (adminErr from net.Listen on adminSockPath) which lets gvproxy_create
succeed with a broken SSH-forwarding control plane; change gvproxy_create (or
the function that sets up adminSockPath/net.Listen) to treat adminErr as a fatal
startup error: when net.Listen returns a non-nil adminErr, propagate/return that
error (or wrap it with context) instead of continuing, and ensure any prior
cleanup (removed stale socket) is left intact; keep the existing
adminServer/Serve and ctx shutdown logic only in the successful branch where
adminListener is non-nil.

---

Nitpick comments:
In `@apps/api/src/sandbox/dto/ssh-access.dto.ts`:
- Around line 122-140: The ApiProperty descriptions for CreateSshAccessBodyDto's
unixUser and unix_user are ambiguous about which is used when both are provided;
update the description strings for both properties to state the precedence
(camelCase unixUser takes precedence over snake_case unix_user) and optionally
reference the controller resolution used (e.g., body?.unixUser?.trim() ||
body?.unix_user?.trim()) so API consumers know camelCase wins; modify the
descriptions on the unixUser and unix_user decorators accordingly.

In `@apps/runner/pkg/api/controllers/ssh_access_test.go`:
- Around line 36-54: TestGatewayOnlyKeysDoesNotIncludeCallerKey currently
declares callerKeys but never passes them to gatewayOnlyKeys, so the loop
asserting they aren't present is redundant; change the test to simply assert
that gatewayOnlyKeys(gwKey) returns exactly []string{gwKey} (i.e., compare
length and element or use reflect.DeepEqual) and remove the unused callerKeys
variable and the nested loop, keeping the test focused on the gatewayOnlyKeys
function's return value.

In `@sdks/c/src/box_handle.rs`:
- Around line 104-131: The inner redundant "unsafe" block inside the exported
function boxlite_box_admin_sock_path should be removed since the function is
already declared unsafe extern "C"; simply use handle, dereference to &*handle
(CBoxHandle) and perform the same null checks and path construction (using
handle_ref.home_dir, handle_ref.box_id) directly in the function body,
preserving the CString::new(...) match and returning s.into_raw() or
ptr::null_mut() as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 708c37cf-9044-491f-a98c-25e3229f885a

📥 Commits

Reviewing files that changed from the base of the PR and between 71e1754 and 095df7c.

⛔ Files ignored due to path filters (1)
  • apps/ssh-gateway/go.sum is excluded by !**/*.sum
📒 Files selected for processing (90)
  • .github/workflows/build-runtime.yml
  • .gitignore
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • apps/api-client-go/model_ssh_access_validation_dto_test.go
  • apps/api/jest.config.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api/src/admin/controllers/sandbox.controller.ts
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • apps/api/src/docker-registry/controllers/docker-registry.controller.ts
  • apps/api/src/interceptors/metrics.interceptor.ts
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/organization/controllers/organization.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/sandbox/common/redis-lock.provider.ts
  • apps/api/src/sandbox/controllers/runner.controller.ts
  • apps/api/src/sandbox/controllers/sandbox.controller.ts
  • apps/api/src/sandbox/controllers/snapshot.controller.ts
  • apps/api/src/sandbox/controllers/toolbox.deprecated.controller.ts
  • apps/api/src/sandbox/controllers/volume.controller.ts
  • apps/api/src/sandbox/controllers/workspace.deprecated.controller.ts
  • apps/api/src/sandbox/dto/ssh-access.dto.ts
  • apps/api/src/sandbox/entities/ssh-access.entity.ts
  • apps/api/src/sandbox/runner-adapter/runnerAdapter.ts
  • apps/api/src/sandbox/runner-adapter/runnerAdapter.v0.ts
  • apps/api/src/sandbox/runner-adapter/runnerAdapter.v2.ts
  • apps/api/src/sandbox/services/sandbox-ssh-access.spec.ts
  • apps/api/src/sandbox/services/sandbox.service.ts
  • apps/api/src/user/user.controller.ts
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/api/tsconfig.spec.json
  • apps/apps/tsconfig.base.json
  • apps/dashboard/src/components/ErrorBoundaryFallback.tsx
  • apps/dashboard/src/hooks/mutations/useCreateSandboxMutation.tsx
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • apps/dashboard/src/hooks/useSandboxSession.ts
  • apps/dashboard/src/pages/Sandboxes.tsx
  • apps/infra/sst.config.ts
  • apps/libs/api-client/src/api/sandbox-api.ts
  • apps/package.json
  • apps/runner/README.md
  • apps/runner/cmd/runner/config/config.go
  • apps/runner/cmd/runner/main.go
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • apps/runner/pkg/api/server.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/runner/pkg/boxlite/client_ssh_test.go
  • apps/runner/pkg/boxlite/exec_manager.go
  • apps/runner/pkg/runner/runner.go
  • apps/runner/pkg/sshgateway/service.go
  • apps/runner/pkg/sshgateway/ssh_integration_test.go
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/runner/pkg/sshport/allocator.go
  • apps/runner/pkg/sshport/allocator_test.go
  • apps/ssh-gateway/main.go
  • apps/ssh-gateway/main_test.go
  • jest.preset.js
  • make/build.mk
  • make/test.mk
  • openapi/box.openapi.yaml
  • scripts/build/build-static-sshd.sh
  • scripts/deploy/runner-update-binary.sh
  • sdks/c/include/boxlite.h
  • sdks/c/include/boxlite_internal.h
  • sdks/c/src/box_handle.rs
  • sdks/c/src/event_queue.rs
  • sdks/c/src/rest.rs
  • sdks/c/src/runtime.rs
  • sdks/c/src/tests.rs
  • sdks/go/box_handle.go
  • sdks/go/boxlite_internal.h
  • sdks/go/bridge.h
  • sdks/go/exec.go
  • src/boxlite/build.rs
  • src/boxlite/src/net/gvproxy/config.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/runtime/layout.rs
  • src/deps/libgvproxy-sys/gvproxy-bridge/main.go
  • src/guest/scripts/boxlite-disable-ssh
  • src/guest/scripts/boxlite-enable-ssh
  • src/guest/scripts/boxlite-ensure-ssh
  • src/guest/src/container/spec.rs
  • tsconfig.base.json

Comment thread apps/api/src/migrations/1778751201300-migration.ts Outdated
Comment thread apps/api/src/box/common/redis-lock.provider.ts Outdated
Comment thread apps/api/src/sandbox/services/sandbox.service.ts Outdated
Comment thread apps/libs/api-client/src/api/sandbox-api.ts Outdated
Comment thread apps/runner/cmd/runner/config/config.go Outdated
Comment thread apps/ssh-gateway/main_test.go
Comment thread openapi/box.openapi.yaml Outdated
Comment thread scripts/build/build-static-sshd.sh
Comment thread src/boxlite/src/rootfs/guest.rs
Comment thread src/deps/libgvproxy-sys/gvproxy-bridge/main.go Outdated
G4614 pushed a commit to G4614/boxlite that referenced this pull request Jun 15, 2026
## Summary

- Remove the stale `box_public_request: request.public` create-box
metric after boxlite-ai#762 moved metrics to the REST create DTO.
- Replace the Tailwind v4-only toggle group gap class with valid
Tailwind 3/CSS-var output.
- Make `dashboard:build` depend on `api-client:build` so clean Docker
builds have `dist/libs/api-client` before dashboard typecheck.

## Root cause

`sst deploy --stage dev` failed while building the API image. The first
blocker was `api:build:production` typechecking `request.public` against
the REST `CreateBoxDto`, which intentionally has no `public` field.
After that was fixed, the same clean Docker image path exposed two
dashboard production blockers: invalid generated CSS from
`gap-[--spacing(var(--gap))]`, and a missing `api-client` build
dependency.

## Validation

- `git diff --check`
- Remote dev machine: `cd apps && NX_DAEMON=false yarn nx build api
--configuration=production --nxBail=true --output-style=stream`
- Remote dev machine: `docker build -f apps/api/Dockerfile --target
boxlite .`

## Notes

I checked current open PRs before opening this. boxlite-ai#719 and boxlite-ai#541 both touch
`metrics.interceptor.ts`, but neither contains this stale
`request.public` fix; boxlite-ai#719 is also a broad draft for older API
production webpack issues.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Enhanced toggle group component spacing behavior for improved visual
consistency

* **Chores**
* Updated build dependency configuration for dashboard production builds
  * Refined analytics metric collection payload structure

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 095df7c to df13edd Compare July 6, 2026 03:32
@nieyy nieyy requested a review from a team as a code owner July 6, 2026 03:32
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from df13edd to 6fb3f75 Compare July 6, 2026 19:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/api/src/box/services/box.service.ts (1)

1665-1669: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract duplicated SSH gateway URL resolution.

This region-lookup + config fallback block is duplicated verbatim at Lines 1616-1620 (second fencing return). Two copies must stay in sync; extract a small private helper (e.g. resolveSshGatewayUrl(box)) and reuse it in both return sites.

♻️ Suggested helper
private async resolveSshGatewayUrl(box: Box): Promise<string> {
  const region = await this.regionService.findOne(box.region, true)
  return region?.sshGatewayUrl ?? this.configService.getOrThrow('sshGateway.url')
}

Then both return sites become:

return SshAccessDto.fromSshAccess(sshAccess, await this.resolveSshGatewayUrl(box))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/box/services/box.service.ts` around lines 1665 - 1669, Extract
the duplicated SSH gateway URL lookup logic into a small private helper on
BoxService, such as resolveSshGatewayUrl(box), so both fencing return sites
share the same regionService.findOne plus configService fallback behavior. Have
the helper return region?.sshGatewayUrl ??
this.configService.getOrThrow('sshGateway.url'), and update the two
SshAccessDto.fromSshAccess call sites to await the helper instead of repeating
the inline block.
apps/api/src/box/runner-adapter/runnerAdapter.v2.ts (1)

250-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Discards backend error detail on failure.

Non-2xx responses are converted to a bare HTTP {status} message, dropping the runner's {"error": "..."} payload (e.g. specific validation or state errors from EnableSSHAccess). This makes failures harder to diagnose from the API layer.

♻️ Suggested fix: surface backend error message
     if (response.status < 200 || response.status >= 300) {
+      const detail = typeof response.data === 'object' && response.data?.error ? response.data.error : `HTTP ${response.status}`
       throw new Error(
-        `enableSSHAccess failed for sandbox ${sandboxId} on runner ${this.runner.id}: HTTP ${response.status}`,
+        `enableSSHAccess failed for sandbox ${sandboxId} on runner ${this.runner.id}: ${detail}`,
       )
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/box/runner-adapter/runnerAdapter.v2.ts` around lines 250 - 270,
The enableSSHAccess failure path in runnerAdapter.v2.ts is dropping the
backend’s error payload and only reporting the HTTP status. Update the
enableSSHAccess method to inspect the non-2xx axios response body and include
the returned error message (for example the runner’s error field) in the thrown
error, while keeping the existing 503 special-case handling intact. Use the
enableSSHAccess and axiosInstance request logic to locate the change, and make
the generic failure branch preserve backend detail instead of emitting only HTTP
{status}.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/box/runner-adapter/runnerAdapter.v2.ts`:
- Around line 32-34: RunnerAdapterV2’s direct runner HTTP client is created
without retry support, so transient failures in enableSSHAccess and
disableSSHAccess are not retried. Update the axiosInstance setup in
RunnerAdapterV2 to mirror the V0 retry configuration by applying axiosRetry when
the instance is created for runner.apiUrl, and ensure the direct SSH runner
calls continue to use that retried client.

In `@apps/infra/sst.config.ts`:
- Line 720: The SSH gateway is using the default runner token instead of the
per-runner token, so boxes on extra runners can fail authentication. Update the
token wiring around RUNNER_API_TOKEN in the infra config and the
getRunnerSSHAccess call path so it uses the same BOXLITE_RUNNER_TOKEN value
assigned to each runner (rather than defaultRunnerApiKey.result), keeping the
runner-specific token aligned end to end.

---

Nitpick comments:
In `@apps/api/src/box/runner-adapter/runnerAdapter.v2.ts`:
- Around line 250-270: The enableSSHAccess failure path in runnerAdapter.v2.ts
is dropping the backend’s error payload and only reporting the HTTP status.
Update the enableSSHAccess method to inspect the non-2xx axios response body and
include the returned error message (for example the runner’s error field) in the
thrown error, while keeping the existing 503 special-case handling intact. Use
the enableSSHAccess and axiosInstance request logic to locate the change, and
make the generic failure branch preserve backend detail instead of emitting only
HTTP {status}.

In `@apps/api/src/box/services/box.service.ts`:
- Around line 1665-1669: Extract the duplicated SSH gateway URL lookup logic
into a small private helper on BoxService, such as resolveSshGatewayUrl(box), so
both fencing return sites share the same regionService.findOne plus
configService fallback behavior. Have the helper return region?.sshGatewayUrl ??
this.configService.getOrThrow('sshGateway.url'), and update the two
SshAccessDto.fromSshAccess call sites to await the helper instead of repeating
the inline block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e003c7ca-8127-4ad9-830c-0fd98d4e58c5

📥 Commits

Reviewing files that changed from the base of the PR and between df13edd and 6fb3f75.

⛔ Files ignored due to path filters (1)
  • apps/ssh-gateway/go.sum is excluded by !**/*.sum
📒 Files selected for processing (79)
  • .github/workflows/build-runtime.yml
  • .gitignore
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • apps/api-client-go/model_ssh_access_validation_dto_test.go
  • apps/api/jest.config.ts
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/box/common/redis-lock.provider.ts
  • apps/api/src/box/controllers/box.controller.ts
  • apps/api/src/box/controllers/runner.controller.ts
  • apps/api/src/box/controllers/volume.controller.ts
  • apps/api/src/box/dto/ssh-access.dto.ts
  • apps/api/src/box/entities/ssh-access.entity.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v0.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
  • apps/api/src/box/services/box.service.ts
  • apps/api/src/box/services/sandbox-ssh-access.spec.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/organization/controllers/organization.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/user/user.controller.ts
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/api/tsconfig.spec.json
  • apps/apps/tsconfig.base.json
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • apps/infra/sst.config.ts
  • apps/libs/api-client/src/api/box-api.ts
  • apps/runner/README.md
  • apps/runner/cmd/runner/config/config.go
  • apps/runner/cmd/runner/main.go
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • apps/runner/pkg/api/server.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/runner/pkg/boxlite/client_ssh_test.go
  • apps/runner/pkg/boxlite/exec_manager.go
  • apps/runner/pkg/runner/runner.go
  • apps/runner/pkg/sshgateway/service.go
  • apps/runner/pkg/sshgateway/ssh_integration_test.go
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/runner/pkg/sshport/allocator.go
  • apps/runner/pkg/sshport/allocator_test.go
  • apps/ssh-gateway/main.go
  • apps/ssh-gateway/main_test.go
  • jest.preset.js
  • make/build.mk
  • make/test.mk
  • openapi/box.openapi.yaml
  • scripts/build/build-static-sshd.sh
  • scripts/deploy/runner-update-binary.sh
  • sdks/c/include/boxlite.h
  • sdks/c/include/boxlite_internal.h
  • sdks/c/src/box_handle.rs
  • sdks/c/src/event_queue.rs
  • sdks/c/src/rest.rs
  • sdks/c/src/runtime.rs
  • sdks/c/src/tests.rs
  • sdks/go/box_handle.go
  • sdks/go/boxlite_internal.h
  • sdks/go/bridge.h
  • sdks/go/exec.go
  • src/boxlite/build.rs
  • src/boxlite/src/net/gvproxy/config.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/runtime/layout.rs
  • src/deps/libgvproxy-sys/gvproxy-bridge/main.go
  • src/guest/scripts/boxlite-disable-ssh
  • src/guest/scripts/boxlite-enable-ssh
  • src/guest/scripts/boxlite-ensure-ssh
  • src/guest/src/container/spec.rs
✅ Files skipped from review due to trivial changes (13)
  • apps/api/src/box/controllers/volume.controller.ts
  • sdks/c/src/tests.rs
  • jest.preset.js
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/api/src/box/controllers/runner.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • .gitignore
  • apps/apps/tsconfig.base.json
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/user/user.controller.ts
  • sdks/c/include/boxlite_internal.h
  • sdks/c/src/event_queue.rs
🚧 Files skipped from review as they are similar to previous changes (47)
  • sdks/go/bridge.h
  • .github/workflows/build-runtime.yml
  • sdks/c/src/rest.rs
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • sdks/go/boxlite_internal.h
  • apps/runner/pkg/boxlite/exec_manager.go
  • apps/runner/pkg/sshport/allocator_test.go
  • src/guest/src/container/spec.rs
  • sdks/c/src/runtime.rs
  • src/guest/scripts/boxlite-ensure-ssh
  • make/test.mk
  • src/boxlite/src/net/gvproxy/config.rs
  • src/boxlite/src/rootfs/guest.rs
  • sdks/c/include/boxlite.h
  • scripts/build/build-static-sshd.sh
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • make/build.mk
  • apps/runner/cmd/runner/main.go
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • src/boxlite/build.rs
  • apps/api-client-go/model_ssh_access_validation_dto_test.go
  • src/guest/scripts/boxlite-disable-ssh
  • apps/api/tsconfig.spec.json
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • scripts/deploy/runner-update-binary.sh
  • apps/runner/README.md
  • apps/runner/pkg/sshport/allocator.go
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • src/boxlite/src/runtime/layout.rs
  • openapi/box.openapi.yaml
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • src/guest/scripts/boxlite-enable-ssh
  • apps/runner/cmd/runner/config/config.go
  • sdks/c/src/box_handle.rs
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • sdks/go/box_handle.go
  • sdks/go/exec.go
  • apps/runner/pkg/runner/runner.go
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/runner/pkg/boxlite/client.go
  • apps/ssh-gateway/main.go
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go

Comment thread apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
Comment thread apps/infra/sst.config.ts
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 6fb3f75 to 9c765c0 Compare July 6, 2026 20:18
@nieyy nieyy changed the title feat(ssh): real-SSH access for sandboxes via in-VM sshd + gvproxy port forwarding feat(ssh): real-SSH access via in-VM sshd + gvproxy port forwarding Jul 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api/src/box/common/redis-lock.provider.ts (1)

65-75: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid as any cast for eval; use defineCommand or typed eval.

ioredis already exposes eval on the typed Redis instance, and for reusable Lua scripts it provides defineCommand, which caches the script and auto-selects EVALSHA. The as any cast here is unnecessary and suppresses type-checking on the call arguments.

♻️ Proposed refactor using `defineCommand`
+ constructor(`@InjectRedis`() private readonly redis: Redis) {
+   this.redis.defineCommand('unlockOwned', {
+     numberOfKeys: 1,
+     lua: `
+       if redis.call('get',KEYS[1]) == ARGV[1] then
+         return redis.call('del',KEYS[1])
+       else
+         return 0
+       end`,
+   })
+ }
...
  async unlockOwned(key: string, code: LockCode): Promise<void> {
-   const script = `
-     if redis.call('get',KEYS[1]) == ARGV[1] then
-       return redis.call('del',KEYS[1])
-     else
-       return 0
-     end`
-   await (this.redis as any).eval(script, 1, key, code.getCode())
+   await (this.redis as unknown as { unlockOwned(key: string, code: string): Promise<number> }).unlockOwned(key, code.getCode())
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/box/common/redis-lock.provider.ts` around lines 65 - 75, The
unlockOwned method in RedisLockProvider uses an unnecessary as any cast around
this.redis.eval, which bypasses type checking. Replace the cast by calling the
typed ioredis API directly, or refactor the Lua script into a reusable
defineCommand on RedisLockProvider so the command is strongly typed and cached;
keep the compare-and-delete behavior intact while removing the unsafe cast.
apps/infra/sst.config.ts (1)

1027-1046: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Harden the private-key temp file path.

deriveGatewayPublicKey decodes SSH_PRIVATE_KEY_B64 into a predictable path (/tmp/sst-ssh-gw-${process.pid}). writeFileSync follows an existing symlink at that path, so on a shared build host a pre-planted symlink could redirect the private-key write. Prefer a fresh unpredictable directory.

🔒 Suggested hardening
-  const { writeFileSync, unlinkSync } = require("fs") as typeof import("fs");
-  const tmp = `/tmp/sst-ssh-gw-${process.pid}`;
+  const { writeFileSync, unlinkSync, mkdtempSync, rmSync } = require("fs") as typeof import("fs");
+  const { tmpdir } = require("os") as typeof import("os");
+  const { join } = require("path") as typeof import("path");
+  const dir = mkdtempSync(join(tmpdir(), "sst-ssh-gw-"));
+  const tmp = join(dir, "key");
   try {
     writeFileSync(tmp, Buffer.from(privB64, "base64").toString("utf8"), { mode: 0o600 });
     return execSync(`ssh-keygen -y -f ${tmp}`, { encoding: "utf8" }).trim();
   } catch (err) {
     const reason = err instanceof Error ? err.message : String(err);
     throw new Error(`SSH_GATEWAY_PUBLIC_KEY cannot be derived from SSH_PRIVATE_KEY_B64: ${reason}`);
   } finally {
-    try { unlinkSync(tmp); } catch { /* best-effort cleanup */ }
+    try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/infra/sst.config.ts` around lines 1027 - 1046, deriveGatewayPublicKey
currently writes the decoded private key to a predictable tmp path, which can be
exploited via a pre-existing symlink. Update the temp-file handling in
deriveGatewayPublicKey to use a freshly created unpredictable location instead
of `/tmp/sst-ssh-gw-${process.pid}`, and keep the existing ssh-keygen and
cleanup flow intact. Reference the deriveGatewayPublicKey helper and its
writeFileSync/unlinkSync usage when making the change.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/runner/pkg/sshgateway/service.go`:
- Around line 155-201: Update the stale docs around startExecFn, the sshUser
field, and sshUserOrDefault() so they all describe the actual default behavior:
empty sshUser resolves to "root", not "boxlite". Adjust the comments on
Service.startExec, Service.sshUser, and sshUserOrDefault() to match the current
implementation and remove the misleading unprivileged-user claim unless the
default behavior itself is being changed.

---

Nitpick comments:
In `@apps/api/src/box/common/redis-lock.provider.ts`:
- Around line 65-75: The unlockOwned method in RedisLockProvider uses an
unnecessary as any cast around this.redis.eval, which bypasses type checking.
Replace the cast by calling the typed ioredis API directly, or refactor the Lua
script into a reusable defineCommand on RedisLockProvider so the command is
strongly typed and cached; keep the compare-and-delete behavior intact while
removing the unsafe cast.

In `@apps/infra/sst.config.ts`:
- Around line 1027-1046: deriveGatewayPublicKey currently writes the decoded
private key to a predictable tmp path, which can be exploited via a pre-existing
symlink. Update the temp-file handling in deriveGatewayPublicKey to use a
freshly created unpredictable location instead of
`/tmp/sst-ssh-gw-${process.pid}`, and keep the existing ssh-keygen and cleanup
flow intact. Reference the deriveGatewayPublicKey helper and its
writeFileSync/unlinkSync usage when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bf7dabac-5de3-4265-bd8a-ba65b161d042

📥 Commits

Reviewing files that changed from the base of the PR and between 6fb3f75 and 9c765c0.

⛔ Files ignored due to path filters (1)
  • apps/ssh-gateway/go.sum is excluded by !**/*.sum
📒 Files selected for processing (79)
  • .github/workflows/build-runtime.yml
  • .gitignore
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • apps/api-client-go/model_ssh_access_validation_dto_test.go
  • apps/api/jest.config.ts
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/box/common/redis-lock.provider.ts
  • apps/api/src/box/controllers/box.controller.ts
  • apps/api/src/box/controllers/runner.controller.ts
  • apps/api/src/box/controllers/volume.controller.ts
  • apps/api/src/box/dto/ssh-access.dto.ts
  • apps/api/src/box/entities/ssh-access.entity.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v0.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
  • apps/api/src/box/services/box.service.ts
  • apps/api/src/box/services/sandbox-ssh-access.spec.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/organization/controllers/organization.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/user/user.controller.ts
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/api/tsconfig.spec.json
  • apps/apps/tsconfig.base.json
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • apps/infra/sst.config.ts
  • apps/libs/api-client/src/api/box-api.ts
  • apps/runner/README.md
  • apps/runner/cmd/runner/config/config.go
  • apps/runner/cmd/runner/main.go
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • apps/runner/pkg/api/server.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/runner/pkg/boxlite/client_ssh_test.go
  • apps/runner/pkg/boxlite/exec_manager.go
  • apps/runner/pkg/runner/runner.go
  • apps/runner/pkg/sshgateway/service.go
  • apps/runner/pkg/sshgateway/ssh_integration_test.go
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/runner/pkg/sshport/allocator.go
  • apps/runner/pkg/sshport/allocator_test.go
  • apps/ssh-gateway/main.go
  • apps/ssh-gateway/main_test.go
  • jest.preset.js
  • make/build.mk
  • make/test.mk
  • openapi/box.openapi.yaml
  • scripts/build/build-static-sshd.sh
  • scripts/deploy/runner-update-binary.sh
  • sdks/c/include/boxlite.h
  • sdks/c/include/boxlite_internal.h
  • sdks/c/src/box_handle.rs
  • sdks/c/src/event_queue.rs
  • sdks/c/src/rest.rs
  • sdks/c/src/runtime.rs
  • sdks/c/src/tests.rs
  • sdks/go/box_handle.go
  • sdks/go/boxlite_internal.h
  • sdks/go/bridge.h
  • sdks/go/exec.go
  • src/boxlite/build.rs
  • src/boxlite/src/net/gvproxy/config.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/runtime/layout.rs
  • src/deps/libgvproxy-sys/gvproxy-bridge/main.go
  • src/guest/scripts/boxlite-disable-ssh
  • src/guest/scripts/boxlite-enable-ssh
  • src/guest/scripts/boxlite-ensure-ssh
  • src/guest/src/container/spec.rs
✅ Files skipped from review due to trivial changes (11)
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/apps/tsconfig.base.json
  • sdks/c/src/tests.rs
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/user/user.controller.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • .gitignore
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/runner/README.md
🚧 Files skipped from review as they are similar to previous changes (61)
  • sdks/go/bridge.h
  • jest.preset.js
  • sdks/go/boxlite_internal.h
  • apps/api/src/box/entities/ssh-access.entity.ts
  • sdks/c/include/boxlite_internal.h
  • sdks/go/box_handle.go
  • sdks/c/src/event_queue.rs
  • sdks/c/include/boxlite.h
  • apps/runner/cmd/runner/main.go
  • apps/api/src/box/controllers/runner.controller.ts
  • .github/workflows/build-runtime.yml
  • make/test.mk
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/runner/pkg/boxlite/exec_manager.go
  • src/boxlite/src/rootfs/guest.rs
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/runner/pkg/api/server.go
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • apps/runner/pkg/runner/runner.go
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • apps/api/tsconfig.spec.json
  • apps/runner/pkg/sshport/allocator.go
  • apps/api/src/box/controllers/volume.controller.ts
  • apps/api/src/organization/controllers/organization.controller.ts
  • sdks/c/src/rest.rs
  • scripts/build/build-static-sshd.sh
  • src/boxlite/src/net/gvproxy/config.rs
  • apps/api/src/box/dto/ssh-access.dto.ts
  • apps/api/jest.config.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api-client-go/model_ssh_access_validation_dto_test.go
  • apps/runner/pkg/api/controllers/ssh_access.go
  • src/guest/src/container/spec.rs
  • make/build.mk
  • apps/runner/pkg/sshport/allocator_test.go
  • sdks/c/src/runtime.rs
  • apps/runner/cmd/runner/config/config.go
  • src/guest/scripts/boxlite-enable-ssh
  • apps/api/src/box/runner-adapter/runnerAdapter.ts
  • apps/api/src/migrations/1778751201300-migration.ts
  • scripts/deploy/runner-update-binary.sh
  • src/guest/scripts/boxlite-ensure-ssh
  • apps/api/src/box/runner-adapter/runnerAdapter.v0.ts
  • src/guest/scripts/boxlite-disable-ssh
  • src/boxlite/build.rs
  • openapi/box.openapi.yaml
  • src/boxlite/src/runtime/layout.rs
  • sdks/go/exec.go
  • sdks/c/src/box_handle.rs
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/api/src/box/controllers/box.controller.ts
  • apps/runner/pkg/boxlite/client.go
  • apps/api/src/box/services/box.service.ts
  • apps/libs/api-client/src/api/box-api.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/ssh-gateway/main.go
  • apps/ssh-gateway/main_test.go

Comment thread apps/runner/pkg/sshgateway/service.go
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 9c765c0 to 4fd484d Compare July 7, 2026 00:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
apps/runner/pkg/sshgateway/ssh_integration_test.go (1)

294-298: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

filepath helper shadows the stdlib path/filepath package

Naming this test helper filepath shadows the standard path/filepath package name within this file. If a future test needs filepath.Join/filepath.Base, this will silently resolve to the local function instead, causing a confusing compile error or (if never imported) simply blocking that import.

♻️ Proposed rename
-// filepath returns a path inside t.TempDir() with the given base name.
-func filepath(t *testing.T, name string) string {
+// tempFilePath returns a path inside t.TempDir() with the given base name.
+func tempFilePath(t *testing.T, name string) string {
 	t.Helper()
 	return t.TempDir() + "/" + name
 }

Update the two call sites (dst := filepath(t, "dst.bin")) accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/runner/pkg/sshgateway/ssh_integration_test.go` around lines 294 - 298,
The test helper named filepath in ssh_integration_test.go shadows the standard
path/filepath package name, which can cause future imports or calls like
filepath.Join/Base to resolve incorrectly. Rename the local helper to a less
conflicting name, and update its call sites in the SSH integration tests
(including the dst := filepath(t, "dst.bin") usages) to match the new helper
name.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api-client-go/api/openapi.yaml`:
- Around line 2461-2466: The SSH access endpoint request body was made required
in the OpenAPI spec, which forces SDK callers to always send a body even though
the server accepts it as optional. Update the requestBody definition for
CreateSshAccessBodyDto in the OpenAPI schema to be optional again so it matches
BoxController’s `@Body`() body?: CreateSshAccessBodyDto behavior and avoids the
generated CreateSshAccessExecute hard-fail in the Go client. Keep the change
scoped to the SSH access operation and ensure the schema still allows an empty
or omitted body.

In `@apps/api/src/box/dto/ssh-access.dto.ts`:
- Around line 132-143: The CreateSshAccessBodyDto fields unixUser and unix_user
need a POSIX username allowlist before guest provisioning, since unsafe values
can break interpolation in the guest script and inject config into /etc/passwd,
/etc/sudoers.d, HOME_DIR, and the AllowUsers heredoc. Add validation directly on
these DTO properties so only safe username characters are accepted, and ensure
both camelCase and snake_case inputs are covered by the same rule in
CreateSshAccessBodyDto.

In `@apps/infra/sst.config.ts`:
- Around line 1024-1046: The SSH gateway public key derivation is reading from
process.env, so it misses the SST secret value and can produce an empty key in
the normal deploy path. Update deriveGatewayPublicKey() to accept the secret
value directly, pass sshPrivateKey.value into it, and reuse the derived result
at both runner user-data call sites so the same key is used consistently.

In `@apps/runner/pkg/boxlite/client_ssh.go`:
- Around line 416-433: Persist the degraded SSH state in the rollback path of
client_ssh.go before returning from enableSSHAccess, so the failure case is
recoverable after restart. Update the same SSH state bookkeeping used by
reconcileSSHState and the existing c.sshStates/c.sshBoxes tracking so the
removed gvproxy forward is recorded in durable state, not only in memory, and
ensure the reserved port remains associated with the box across crashes.

---

Nitpick comments:
In `@apps/runner/pkg/sshgateway/ssh_integration_test.go`:
- Around line 294-298: The test helper named filepath in ssh_integration_test.go
shadows the standard path/filepath package name, which can cause future imports
or calls like filepath.Join/Base to resolve incorrectly. Rename the local helper
to a less conflicting name, and update its call sites in the SSH integration
tests (including the dst := filepath(t, "dst.bin") usages) to match the new
helper name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 381464b9-cae8-4c7f-8a55-64d66dd0ca5a

📥 Commits

Reviewing files that changed from the base of the PR and between 9c765c0 and 4fd484d.

⛔ Files ignored due to path filters (1)
  • apps/ssh-gateway/go.sum is excluded by !**/*.sum
📒 Files selected for processing (87)
  • .github/workflows/build-runtime.yml
  • .gitignore
  • apps/api-client-go/.openapi-generator/FILES
  • apps/api-client-go/api/openapi.yaml
  • apps/api-client-go/api_box.go
  • apps/api-client-go/model_create_ssh_access_body_dto.go
  • apps/api-client-go/model_ssh_access_validation_dto.go
  • apps/api/jest.config.ts
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api/src/api-key/api-key.controller.ts
  • apps/api/src/box/common/redis-lock.provider.ts
  • apps/api/src/box/controllers/box.controller.ts
  • apps/api/src/box/controllers/runner.controller.ts
  • apps/api/src/box/controllers/volume.controller.ts
  • apps/api/src/box/dto/ssh-access.dto.ts
  • apps/api/src/box/entities/ssh-access.entity.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v0.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
  • apps/api/src/box/services/box.service.ts
  • apps/api/src/box/services/sandbox-ssh-access.spec.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/organization/controllers/organization.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/user/user.controller.ts
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/api/tsconfig.spec.json
  • apps/dashboard/src/hooks/mutations/useCreateSshAccessMutation.ts
  • apps/infra/sst.config.ts
  • apps/libs/api-client/src/.openapi-generator/FILES
  • apps/libs/api-client/src/api/box-api.ts
  • apps/libs/api-client/src/docs/BoxApi.md
  • apps/libs/api-client/src/docs/CreateSshAccessBodyDto.md
  • apps/libs/api-client/src/docs/SshAccessValidationDto.md
  • apps/libs/api-client/src/models/create-ssh-access-body-dto.ts
  • apps/libs/api-client/src/models/index.ts
  • apps/libs/api-client/src/models/ssh-access-validation-dto.ts
  • apps/runner/README.md
  • apps/runner/cmd/runner/config/config.go
  • apps/runner/cmd/runner/main.go
  • apps/runner/pkg/api/controllers/proxy.go
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • apps/runner/pkg/api/server.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_ssh.go
  • apps/runner/pkg/boxlite/client_ssh_test.go
  • apps/runner/pkg/boxlite/exec_manager.go
  • apps/runner/pkg/runner/runner.go
  • apps/runner/pkg/sshgateway/service.go
  • apps/runner/pkg/sshgateway/ssh_integration_test.go
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/runner/pkg/sshport/allocator.go
  • apps/runner/pkg/sshport/allocator_test.go
  • apps/ssh-gateway/main.go
  • apps/ssh-gateway/main_test.go
  • make/build.mk
  • make/test.mk
  • openapi/box.openapi.yaml
  • scripts/build/build-static-sshd.sh
  • scripts/deploy/runner-update-binary.sh
  • sdks/c/include/boxlite.h
  • sdks/c/include/boxlite_internal.h
  • sdks/c/src/box_handle.rs
  • sdks/c/src/event_queue.rs
  • sdks/c/src/rest.rs
  • sdks/c/src/runtime.rs
  • sdks/c/src/tests.rs
  • sdks/go/box_handle.go
  • sdks/go/boxlite_internal.h
  • sdks/go/bridge.h
  • sdks/go/exec.go
  • src/boxlite/build.rs
  • src/boxlite/src/net/gvproxy/config.rs
  • src/boxlite/src/rootfs/guest.rs
  • src/boxlite/src/runtime/layout.rs
  • src/deps/libgvproxy-sys/gvproxy-bridge/main.go
  • src/guest/scripts/boxlite-disable-ssh
  • src/guest/scripts/boxlite-enable-ssh
  • src/guest/scripts/boxlite-ensure-ssh
  • src/guest/src/container/spec.rs
✅ Files skipped from review due to trivial changes (19)
  • apps/libs/api-client/src/models/create-ssh-access-body-dto.ts
  • apps/api/src/organization/controllers/organization-user.controller.ts
  • apps/api/src/box/controllers/volume.controller.ts
  • apps/libs/api-client/src/.openapi-generator/FILES
  • apps/api/src/admin/controllers/box.controller.ts
  • apps/api/src/organization/controllers/organization-role.controller.ts
  • apps/api/src/organization/controllers/organization-invitation.controller.ts
  • apps/api/src/user/user.controller.ts
  • apps/runner/README.md
  • sdks/c/src/rest.rs
  • apps/api/src/organization/controllers/organization-region.controller.ts
  • apps/api/src/box/controllers/runner.controller.ts
  • apps/api/src/organization/guards/organization-access.guard.ts
  • apps/api/src/webhook/controllers/webhook.controller.ts
  • apps/api/src/admin/controllers/runner.controller.ts
  • apps/api-client-go/model_create_ssh_access_body_dto.go
  • .gitignore
  • sdks/c/src/event_queue.rs
  • apps/api/src/organization/controllers/organization.controller.ts
🚧 Files skipped from review as they are similar to previous changes (49)
  • sdks/go/boxlite_internal.h
  • sdks/c/src/tests.rs
  • apps/runner/pkg/api/server.go
  • sdks/go/bridge.h
  • apps/api/src/box/entities/ssh-access.entity.ts
  • sdks/go/box_handle.go
  • sdks/c/include/boxlite.h
  • apps/api/src/api-key/api-key.controller.ts
  • sdks/c/include/boxlite_internal.h
  • src/boxlite/src/runtime/layout.rs
  • apps/runner/pkg/boxlite/exec_manager.go
  • make/test.mk
  • scripts/build/build-static-sshd.sh
  • apps/runner/cmd/runner/main.go
  • apps/api/jest.config.ts
  • apps/api/src/boxlite-rest/boxlite-box.controller.ts
  • .github/workflows/build-runtime.yml
  • apps/api/src/migrations/1778751201300-migration.ts
  • apps/api/tsconfig.spec.json
  • apps/runner/cmd/runner/config/config.go
  • src/guest/scripts/boxlite-ensure-ssh
  • src/guest/src/container/spec.rs
  • apps/runner/pkg/api/controllers/proxy.go
  • make/build.mk
  • apps/runner/pkg/sshport/allocator_test.go
  • sdks/go/exec.go
  • apps/api/src/box/runner-adapter/runnerAdapter.ts
  • apps/runner/pkg/sshport/allocator.go
  • src/guest/scripts/boxlite-disable-ssh
  • scripts/deploy/runner-update-binary.sh
  • src/boxlite/src/rootfs/guest.rs
  • apps/runner/pkg/api/controllers/ssh_access_test.go
  • sdks/c/src/runtime.rs
  • apps/api/src/box/runner-adapter/runnerAdapter.v2.ts
  • src/boxlite/build.rs
  • src/boxlite/src/net/gvproxy/config.rs
  • apps/runner/pkg/runner/runner.go
  • sdks/c/src/box_handle.rs
  • apps/api/src/box/common/redis-lock.provider.ts
  • apps/api/src/box/runner-adapter/runnerAdapter.v0.ts
  • openapi/box.openapi.yaml
  • apps/runner/pkg/api/controllers/ssh_access.go
  • apps/runner/pkg/boxlite/client.go
  • apps/api/src/box/controllers/box.controller.ts
  • apps/ssh-gateway/main_test.go
  • apps/api/src/box/services/box.service.ts
  • apps/runner/pkg/sshgateway/stdin_cancel_test.go
  • apps/ssh-gateway/main.go
  • src/guest/scripts/boxlite-enable-ssh

Comment thread apps/api-client-go/api/openapi.yaml Outdated
Comment thread apps/api/src/box/dto/ssh-access.dto.ts
Comment thread apps/infra/sst.config.ts
Comment thread apps/runner/pkg/boxlite/client_ssh.go
nieyy added a commit to nieyy/boxlite that referenced this pull request Jul 7, 2026
Fixes 11 issues flagged during review of the real-SSH feature:

- openapi/box.openapi.yaml: remove orphaned duplicate description key under
  CreateSshAccessRequest.unix_user
- box.service.ts: revokeSshAccess fails closed (instead of silently deleting
  the DB token) when the runner record is missing but real-SSH state exists
- redis-lock.provider.ts: waitForLock/waitForLockOwned take an explicit
  timeout instead of polling forever when a lock is never released
- runner config.go: validate SSH_PORT_BASE/SSH_PORT_POOL_SIZE bounds so an
  invalid range fails at startup instead of allocating out-of-range ports
- ssh-gateway main_test.go: fix data races on plain bools read/written from
  goroutines by switching to atomic.Bool
- guest.rs: fold an SSH-assets hash (sshd/ssh-keygen binaries + management
  scripts) into the rootfs version key, so rebuilding them invalidates the
  cache instead of being silently served from a stale rootfs
- gvproxy-bridge main.go: gvproxy_create now fails (instead of only logging)
  when the admin HTTP server can't bind its Unix socket, since a broken
  admin server otherwise surfaces later as an unexplained SSH forwarding
  failure
- runnerAdapter.v2.ts: retry enableSSHAccess/disableSSHAccess on transient
  network errors (ECONNRESET/ETIMEDOUT), matching RunnerAdapterV0
- migration comment: correct NULL semantics (legacy exec-bridge token, not
  "treated as boxlite")
- runner README + sshgateway service.go comments: correct the documented
  default SSH exec user from stale "boxlite" to the actual "root" default
- build-static-sshd.sh: validate Docker availability/reachability and output
  directory writability before running, instead of failing deep inside the
  Docker build with a confusing error

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 2 times, most recently from 1c4300c to 88f2209 Compare July 7, 2026 02:34
@nieyy nieyy requested a review from lilongen July 7, 2026 02:50
@DorianZheng

Copy link
Copy Markdown
Member

a design note on the sshd choice: since the guest agent is rust, the tempting alternative was embedding an ssh library in it — russh is mature (warpgate, a production ssh gateway, is built on it), and that route would delete the whole openssh supply line: the alpine/musl ci build, dist/ bundling, ext4 injection, bind mounts, even ssh-keygen.

why real openssh is still the right call here: with an embedded library, our own code becomes the security guard — "run this session as alice, with alice's permissions, nobody else's" turns into hand-rolled fork/setuid with no AllowUsers and no privilege separation. that's the known weak spot of the embedded approach (daytona's in-sandbox daemon takes it: gliderlabs server accepting any pubkey, a hardcoded password, one shared uid, exit codes flattened to 127). since unix_user enforcement is this pr's core security contract, a 25-year-audited sshd at the login boundary beats a library plus homemade login logic.

rule of thumb: embed the library where we're a broker (the external gateway could be russh one day), exec the real sshd where we're the login target. this pr lands on the right side of that line.

@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch 3 times, most recently from c300265 to abc52ff Compare July 8, 2026 00:39
@nieyy

nieyy commented Jul 8, 2026

Copy link
Copy Markdown
Author

a design note on the sshd choice...

Yeah, I hadn't looked into russh before — but I was already convinced this had to be built on real sshd from the start. Appreciate the validation!

@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from abc52ff to 52f6bb0 Compare July 8, 2026 17:40
@boxlite-agent

boxlite-agent Bot commented Jul 8, 2026

Copy link
Copy Markdown

📦 BoxLite review — 3 issues · c76a6de

Review evidence

  • git diff --numstat origin/main...HEAD && git diff origin/main...HEAD — 17.6k line diff across 110 files, ssh feature
  • Read apps/runner/pkg/boxlite/client_ssh.go:813-853 + client.go:416-430 — confirmed boxSSHMu map-entry leak on Destroy
  • Read apps/runner/pkg/sshgateway/service.go:249-380 + diff hunk 11134-11236 — confirmed exec ctx no longer derives from Start's ctx
  • grep client_ssh_test.go for cleanupSSHOnDestroy coverage — tests cover state-exists path only, not ok=false leak
  • go test ./apps/runner/... — skipped: 4 parallel review agents already exercised code paths; time-bounded

Risk notes

  • runner SSH state lifecycle — boxSSHMu per-box mutex map leaks one entry per Destroy() when box never enabled SSH (common case); unbounded growth over runner process lifetime, verified by reading cleanupSSHOnDestroy early-return before delete(c.boxSSHMu,...)
  • sshgateway shutdown/cancellation — handleChannel now uses context.Background() instead of ctx derived from Start(ctx); cancelling gateway ctx no longer tears down in-flight exec sessions/guest processes, verified via diff hunk showing execCtx:=WithCancel(ctx) replaced by WithCancel(Background())
  • unixUser injection — validated via regex at DTO (ssh-access.dto.ts), runner controller (ssh_access.go), and shell-quoted in client_ssh.go before reaching boxlite-enable-ssh; no live injection path found, though only enforced at HTTP boundary not service layer (defense-in-depth gap, not exploitable today)
  • redis-lock / fencing — lock() uses SET NX, unlockOwned uses compare-and-delete Lua script, bounded retries; sandbox-ssh-access.spec.ts has extensive fencing coverage — looks safe
  • DB migration — additive nullable unixUser column, IF NOT EXISTS/IF EXISTS, reversible down — safe
  • guest scripts (boxlite-enable-ssh/disable/ensure-ssh) — fixed /tmp/boxlite-sshd.pid path with no O_NOFOLLOW/ownership check before kill/pidfile write; symlink-based DoS possible from a second unix user in same guest, low severity given single-tenant-per-VM trust model
  • coverage/sampling — sampled via 4 parallel sub-reviews rather than reading every line myself: apps-client-go generated models, sdks/c, sdks/go, apps/infra/sst.config.ts, apps/dashboard hook, openapi specs, and make/build.mk changes were not independently inspected — treated as low-risk generated/config glue
apps/runner/pkg/boxlite/client_ssh.go
  cleanupSSHOnDestroy  +1284/-0 (new file)  mutex map leak on no-op destroy
apps/runner/pkg/boxlite/client.go
  Destroy  +98/-11  unconditionally calls cleanup
apps/runner/pkg/sshgateway/service.go
  Start/handleConnection/handleChannel  +772/-307  exec ctx decoupled from shutdown
apps/runner/pkg/sshport/allocator.go
  Allocator  +115/-0 (new)  mutex-guarded, idempotent release, ok
apps/runner/pkg/api/controllers/ssh_access.go
  EnableSSHAccess  +222/-0 (new)  Enabled:true even if forward degraded
apps/api/src/box/services/box.service.ts
  createSshAccess  +317/-37  unixUser trusted from DTO only
apps/api/src/box/common/redis-lock.provider.ts
  lock/unlockOwned  +45/-2  CAS unlock via Lua, safe
apps/api/src/migrations/1778751201300-migration.ts
  up/down  +32/-0  additive nullable column, safe
src/guest/scripts/boxlite-enable-ssh
  main  +111/-0 (new)  fixed pidfile path, no symlink guard
apps/ssh-gateway/main.go
  connectToRunner  +289/-61  InsecureIgnoreHostKey for real sshd path
3 findings summary
  • ⚠️ apps/runner/pkg/boxlite/client_ssh.go:819-829 boxSSHMu mutex map entry leaks on Destroy — boxSSHMutex(boxId) inserts an entry into c.boxSSHMu before the if !ok { return } guard, which fires for every box that never enabled SSH, so delete(c.boxSSHMu, boxId) at line 851 is never reached — unbounded map growth per Destroy() call over the runner's lifetime.
  • ⚠️ apps/runner/pkg/sshgateway/service.go:377-378 exec context no longer bound to Start's shutdown ctx — handleChannel builds its context from context.Background() instead of the Start(ctx) passed in (previously execCtx derived from ctx); cancelling the gateway's ctx on shutdown/SIGTERM no longer terminates in-flight exec sessions or their guest processes — they run until the client disconnects.
  • 🧹 apps/api/src/box/services/box.service.ts:1280-1360 unixUser validated only at DTO boundary — createSshAccess passes unixUser straight to adapter.enableSSHAccess with no re-validation in the service layer; only the HTTP DTO regex protects the guest /etc/passwd write in boxlite-enable-ssh, so any future internal caller bypassing the controller could inject a crafted username.

reviewed c76a6de in a BoxLite microVM · @boxlite-agent review to re-run · powered by BoxLite

@nieyy

nieyy commented Jul 9, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review — all 4 are fixed now:

  1. boxlite-disable-ssh root-home asymmetry: it now branches on root the same way boxlite-enable-ssh does (HOME_DIR="/root" vs /home/$UNIX_USER), so disable correctly removes the marker for the controller's default root user.
    src/guest/scripts/boxlite-disable-ssh#L6-L7

  2. sshUserOrDefault() docs now match the actual root default (updated the startExecFn doc, the sshUser field doc, and the function doc — all previously said "boxlite").
    apps/runner/pkg/sshgateway/service.go#L158-L195

  3. boxSSHMu entries are now deleted in cleanupSSHOnDestroy, so the map no longer grows unbounded across the runner process lifetime.
    apps/runner/pkg/boxlite/client_ssh.go#L850-L852

  4. SSH bearer tokens are redacted before logging (8-char prefix + ) on the fail-closed path.
    apps/ssh-gateway/main.go#L253, #L669-L677

Appreciate the catches — #3 and #4 in particular would've been nasty to track down in prod.

Adds full SSH (SFTP, scp, port forwarding, non-PTY exec) to BoxLite sandboxes by
routing SSH-access tokens through a per-sandbox port on the Runner EC2 directly to
a real sshd running inside the container namespace, bypassing the Rust exec bridge
that blocks binary streams. The external SSH Gateway detects real-SSH tokens (unix_user
non-null) and TCP-proxies to the allocated port; legacy exec-bridge tokens fall through
unchanged.

Security contract:
- Only the gateway public key is installed in guest sshd authorized_keys
- Real-SSH tokens require unix_user; empty/absent → null (exec-bridge) at controller boundary
- Gateway is fail-closed on every error path: lookup failure, degraded state, unix_user
  mismatch, missing RUNNER_API_TOKEN, empty unix_user string
- Per-sandbox Redis lock (90s TTL, compare-and-delete) guards concurrent enable/disable
- Expired row cleanup covers all three cases: runner-attached, runner-less, null sandbox
- Non-expired token with null sandbox (concurrent delete) returns invalid cleanly
- Revocation is fail-closed (disable-before-delete): runner unreachable → error propagated,
  DB row preserved for retry; legacy exec-bridge tokens use best-effort runner cleanup
- PermitRootLogin set to prohibit-password; default SSH user is root
- OpenSSH tarball SHA256 verified in CI before build (supply-chain)
- SSH bearer tokens redacted in gateway logs (8-char prefix kept for correlation)
- Real-SSH classification derives from GetUnixUser() != "" rather than the generated
  client's HasUnixUser(), which reports key-presence (true even for explicit null) and
  would misclassify every legacy exec-bridge token as real-SSH
- unixUser/unix_user validated against a POSIX username allowlist before guest
  provisioning: the value is interpolated unquoted into /etc/passwd,
  /etc/sudoers.d/$UNIX_USER, and the sshd AllowUsers config by boxlite-enable-ssh, so
  an unvalidated value could corrupt those files or inject extra sshd directives

Implementation:
- CI statically compiles OpenSSH 9.7p1 (sshd + ssh-keygen) via Alpine musl Docker;
  `make sshd` runs the same script locally; guest.rs injects binaries + management
  scripts into the guest ext4 rootfs at build time
- SSH infra files bind-mounted read-only into container from guest rootfs, gated on
  /boxlite/bin/sshd presence with per-file checks
- boxlite-enable-ssh: UID collision check covers /etc/group; sudoers written before sshd
  starts; passwordless sudo scoped to default 'boxlite' user only; root home dir handled;
  creates sshd privsep user+group when absent (required by non-Alpine images)
- boxlite-disable-ssh: root-home asymmetry fixed (targets /root/.ssh for root user);
  mirrors enable-side sudoers guard
- gvproxy admin socket path carried explicitly in GvproxyConfig (no path arithmetic in
  Go); exposed via C ABI as boxlite_box_admin_sock_path() and cached in SSHState so
  Disable/Reapply/Cleanup do not re-derive it; gvproxy_create fails (instead of only
  logging) when the admin HTTP server can't bind its Unix socket
- gvproxy admin HTTP server handles /services/forwarder/expose for dynamic per-sandbox
  port forwarding to guest sshd (port 22222)
- sst.config.ts: auto-derive SSH_GATEWAY_PUBLIC_KEY from SSH_PRIVATE_KEY_B64 (threaded
  through as the sst.Secret's resolved value, not read from process.env, which never
  contains sst.Secret values inside sst.config.ts); quote key in systemd Environment=
  (spaces); restrict port 22100–22199 ingress to ECS gateway SG
- runner-update-binary.sh: guard SSH_GATEWAY_PUBLIC_KEY injection so running without the
  secret does not erase the live value
- runnerAdapter.v2.ts: implement enableSSHAccess / disableSSHAccess natively with retry
  on transient network errors (ECONNRESET/ETIMEDOUT); 503 surfaces as error, 404 is
  no-op on disable; remove stale apiVersion gate
- box.service.ts: fix remainingRealSsh to count only unixUser≠null rows; add lock-lost
  fencing before runner disable; rollback on create failure skips if prior real-SSH
  session existed; real→legacy rotation calls disableSSHAccess fail-closed; revokeSshAccess
  fails closed instead of silently deleting the DB token when the runner record is
  missing but real-SSH state exists
- client_ssh.go: internalBoxID() with three-level fallback; hostPort=0 handled in
  forward recovery; per-box mutex deleted in cleanupSSHOnDestroy
- ssh-gateway: track pty-req per channel; PTY sessions use Close() on exit so the
  client exits immediately; non-PTY sessions keep CloseWrite() for scp/sftp teardown
- redis-lock.provider.ts: waitForLock/waitForLockOwned take an explicit timeout instead
  of polling forever when a lock is never released
- runner config.go: validate SSH_PORT_BASE/SSH_PORT_POOL_SIZE bounds so an invalid range
  fails at startup instead of allocating out-of-range ports
- guest.rs: fold an SSH-assets hash (sshd/ssh-keygen binaries + management scripts) into
  the rootfs version key, so rebuilding them invalidates the cache instead of being
  silently served from a stale rootfs
- box.controller.ts: @ApiBody({required: false}) on createSshAccess so the generated
  OpenAPI spec (and Go SDK) stop requiring a body the server never required
- OpenAPI: SSH access endpoints + schemas added to box.openapi.yaml; generated
  api-client-go/libs-api-client clients regenerated to match (unix_user hidden from
  schema via @ApiHideProperty to avoid a Go PascalCase field collision with unixUser)
- build-static-sshd.sh: validate Docker availability/reachability and output directory
  writability before running, instead of failing deep inside the Docker build
- corrected stale docs: migration comment (NULL means legacy exec-bridge token, not
  "treated as boxlite"), runner README + sshgateway service.go default SSH exec user
  ("root", not "boxlite")
- 62 regression tests from the initial implementation, plus additional tests added
  for each fix above (DTO validation, port-range bounds, lock timeout, retry behavior,
  rootfs cache-key hashing, admin-socket failure, data-race fixes)
@nieyy nieyy force-pushed the claude/orchestrator/c944e67f branch from 52f6bb0 to c76a6de Compare July 9, 2026 16:06

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📦 BoxLite review — 3 issues

Comment on lines +819 to +829
func (c *Client) cleanupSSHOnDestroy(ctx context.Context, boxId string, alloc *sshport.Allocator) {
mu := c.boxSSHMutex(boxId)
mu.Lock()
defer mu.Unlock()

c.mu.RLock()
state, ok := c.sshStates[boxId]
c.mu.RUnlock()
if !ok {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ boxSSHMu mutex map entry leaks on Destroy
boxSSHMutex(boxId) inserts an entry into c.boxSSHMu before the if !ok { return } guard, which fires for every box that never enabled SSH, so delete(c.boxSSHMu, boxId) at line 851 is never reached — unbounded map growth per Destroy() call over the runner's lifetime.

Suggested change
func (c *Client) cleanupSSHOnDestroy(ctx context.Context, boxId string, alloc *sshport.Allocator) {
mu := c.boxSSHMutex(boxId)
mu.Lock()
defer mu.Unlock()
c.mu.RLock()
state, ok := c.sshStates[boxId]
c.mu.RUnlock()
if !ok {
return
}
func (c *Client) cleanupSSHOnDestroy(ctx context.Context, boxId string, alloc *sshport.Allocator) {
mu := c.boxSSHMutex(boxId)
mu.Lock()
defer mu.Unlock()
defer func() {
c.boxSSHMuMu.Lock()
delete(c.boxSSHMu, boxId)
c.boxSSHMuMu.Unlock()
}()
c.mu.RLock()
state, ok := c.sshStates[boxId]
c.mu.RUnlock()
if !ok {
return
}

Comment on lines +377 to +378
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ exec context no longer bound to Start's shutdown ctx
handleChannel builds its context from context.Background() instead of the Start(ctx) passed in (previously execCtx derived from ctx); cancelling the gateway's ctx on shutdown/SIGTERM no longer terminates in-flight exec sessions or their guest processes — they run until the client disconnects.

@nieyy

nieyy commented Jul 9, 2026

Copy link
Copy Markdown
Author

Need to reimplement via russh, will open another PR.

@nieyy nieyy closed this Jul 9, 2026
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.

4 participants