Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Add SSHWrapper driver#647

Merged
mangelajo merged 3 commits intojumpstarter-dev:mainfrom
mangelajo:ssh-wrap
Sep 29, 2025
Merged

Add SSHWrapper driver#647
mangelajo merged 3 commits intojumpstarter-dev:mainfrom
mangelajo:ssh-wrap

Conversation

@mangelajo
Copy link
Member

@mangelajo mangelajo commented Sep 24, 2025

Configuration

Example configuration:

export:
  ssh:
    type: jumpstarter_driver_ssh.driver.SSHWrapper
    config:
      default_username: "root"
      ssh_command: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
    children:
      tcp:
        type: jumpstarter_driver_network.driver.TcpNetwork
        config:
          host: "192.168.1.100"
          port: 22

Usage

The SSH driver provides a CLI command that accepts all standard SSH arguments:

# Basic SSH connection (uses port forwarding by default)
j ssh

# SSH with direct TCP address
j ssh --direct

# SSH with specific user
j ssh -l myuser

# SSH with other flags
j ssh -i ~/.ssh/id_rsa

# Running a remote command
j ssh ls -la

Summary by CodeRabbit

  • New Features

    • Added an SSH wrapper driver enabling SSH invocation (direct mode, automatic port‑forwarding, username handling, and passthrough of extra SSH arguments) and an example exporter manifest demonstrating ssh→tcp chaining.
  • Documentation

    • Added package README, reference docs, and TOC entry with installation, configuration examples, and CLI usage.
  • Tests

    • Added extensive tests for configuration, CLI behavior, argument processing, port‑forwarding, and error handling.
  • Chores

    • Added package metadata, dependencies, build/test configuration, and workspace registration.

@netlify
Copy link

netlify bot commented Sep 24, 2025

Deploy Preview for jumpstarter-docs ready!

Name Link
🔨 Latest commit 29c2571
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/68da49cd96e3580008111c8d
😎 Deploy Preview https://deploy-preview-647--jumpstarter-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 24, 2025

Walkthrough

Adds a new jumpstarter-driver-ssh package (driver, client, tests, README, example, pyproject), registers it in the workspace pyproject, and updates docs to list and include an SSH driver reference page.

Changes

Cohort / File(s) Summary of changes
Docs updates
docs/source/reference/package-apis/drivers/index.md, docs/source/reference/package-apis/drivers/ssh.md
Adds SSH wrapper driver entry to general-purpose utility drivers and includes ssh.md in the drivers toctree; ssh.md references the package README.
Driver implementation
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py
Adds SSHWrapper dataclass Driver with default_username, ssh_command, validation requiring a tcp child, client() accessor, and getters for config values.
Client implementation
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
Adds SSHWrapperClient (CompositeClient) with Click CLI registration, direct vs port-forward resolution, SSH command assembly helpers, subprocess execution, stream wrappers, and logging/error handling; new stdlib and third-party imports added.
Tests
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
Adds unit tests covering config validation, CLI integration, SSH command composition and precedence rules, direct and forwarded modes, and error cases (e.g., ssh binary missing).
Examples
packages/jumpstarter-driver-ssh/examples/exporter.yaml
Adds an exporter manifest demonstrating exporter → ssh (SSHWrapper) → tcp (TcpNetwork) configuration with default_username.
Package docs
packages/jumpstarter-driver-ssh/README.md
New README describing installation, configuration schema, usage examples, CLI behavior, username/argument rules, dependencies, and API references.
Build/config
packages/jumpstarter-driver-ssh/pyproject.toml, pyproject.toml
Adds package pyproject with metadata, dependencies, build/test configuration; registers jumpstarter-driver-ssh as a workspace source in root pyproject.toml.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant CLI as CLI (Click)
  participant C as SSHWrapperClient
  participant D as SSHWrapper Driver
  participant TCP as TcpNetwork (child)
  participant PF as TcpPortforwardAdapter
  participant SSH as Local ssh

  U->>CLI: jumpstarter ssh [--direct] [args...]
  CLI->>C: invoke command(args, direct)
  C->>D: read config (default_username, ssh_command) & child "tcp"
  alt direct mode
    C->>TCP: resolve direct address
    TCP-->>C: host:port or parse error
    opt parse error
      C->>PF: request port-forward
      PF-->>C: local host:port
    end
  else forwarded mode
    C->>PF: request port-forward
    PF-->>C: local host:port
  end
  C->>C: preprocess args (handle user@host and -l)
  C->>SSH: exec ssh -p <port> [-l user] <host> [opts] [cmd]
  SSH-->>C: exit code
  C-->>CLI: return code
  CLI-->>U: process exit
Loading
sequenceDiagram
  autonumber
  participant C as SSHWrapperClient
  note over C: Fallback from direct parse to forwarding
  C->>TcpNetwork: parse direct address
  TcpNetwork-->>C: parse failure
  C->>TcpPortforwardAdapter: start port-forward
  TcpPortforwardAdapter-->>C: local host:port
  C->>ssh: execute ssh with forwarded port
  ssh-->>C: exit code
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • jumpstarter-dev/jumpstarter#327 — Implements an SSHWrapper driver/client and CLI wrapping TCPNetwork/port-forwarding, which matches the feature added here.

Suggested reviewers

  • kirkbrauer
  • NickCao

Poem

I nibble keys and stitch a hop,
I tunnel packets, never stop.
With username, host, and forwarded port,
I bridge the net from fort to fort.
Hop aboard — SSH, let secrets hop! 🐇🔐

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Add SSHWrapper driver” accurately and concisely reflects the primary change of this pull request, which is the introduction of a new SSHWrapper driver and its associated components. It specifically mentions the core feature being added without unnecessary detail or generic wording. As a result, it provides clear context at a glance for reviewers and maintainers.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/source/reference/package-apis/drivers/ssh.md (1)

89-91: Default ssh_command in docs mismatches code

Driver defaults include “-o LogLevel=ERROR”, but the docs omit it. Sync the default to avoid confusion.

-- `ssh_command`: SSH command to use (default: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null")
+- `ssh_command`: SSH command to use (default: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR")
🧹 Nitpick comments (9)
packages/jumpstarter-driver-ssh/pyproject.toml (1)

27-33: pytest config is fine; consider pinning to 8.4.x

You’re on pytest>=8.3.3 here while the repo is generally on recent 8.4.x. Aligning avoids plugin surprises (async handling changed in 8.4).

Optionally bump:

 [dependency-groups]
 dev = [
-    "pytest-cov>=6.0.0",
-    "pytest>=8.3.3",
+    "pytest-cov>=6.0.0",
+    "pytest>=8.4.2",
 ]
docs/source/reference/package-apis/drivers/ssh.md (1)

61-63: Document security implications of defaults

StrictHostKeyChecking=no and UserKnownHostsFile=/dev/null disable host key verification. Add a short warning and advice to override in production.

packages/jumpstarter-driver-ssh/examples/exporter.yaml (1)

6-8: Avoid committing real tokens

Good that this is a placeholder. Add a brief comment indicating how to source the token (env var, secret manager) to prevent accidental check-ins.

packages/jumpstarter-driver-ssh/README.md (1)

77-79: Add a security note about SSH defaults

Clarify that the defaults disable host key verification and are intended for lab/test usage; recommend overriding for production.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)

14-17: Super call check is unnecessary

Driver already defines post_init; you can call super().post_init() directly.

-    def __post_init__(self):
-        if hasattr(super(), "__post_init__"):
-            super().__post_init__()
+    def __post_init__(self):
+        super().__post_init__()

25-33: Annotate return types for exported accessors

Minor clarity/readability improvement.

-    def get_default_username(self):
+    def get_default_username(self) -> str:
         """Get default SSH username"""
         return self.default_username

-    def get_ssh_command(self):
+    def get_ssh_command(self) -> str:
         """Get the SSH command to use"""
         return self.ssh_command
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (3)

12-16: Replace placeholder test with skip

Mark as skipped to avoid a misleading pass and to document the missing get_driver API surface.

 def test_drivers_ssh():
     """Test that the SSH driver is properly registered"""
-    # Skip this test for now as get_driver is not available
-    pass
+    pytest.skip("get_driver registry not available in this package")

46-53: Assert specific exception type

The driver raises ConfigurationError; assert that explicitly for accuracy.

-from pytest import raises
-with pytest.raises(Exception):  # Should raise ConfigurationError
+from jumpstarter.common.exceptions import ConfigurationError
+with pytest.raises(ConfigurationError):
     SSHWrapper(
         children={},  # Missing tcp child
         default_username=""
     )

320-339: Precedence test for -l vs user@host is helpful

Ensures explicit -l wins. Consider adding an assertion that multiple -l flags don’t reorder or drop values inadvertently.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a063824 and 8d078e6.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • docs/source/reference/package-apis/drivers/index.md (2 hunks)
  • docs/source/reference/package-apis/drivers/ssh.md (1 hunks)
  • packages/jumpstarter-driver-ssh/README.md (1 hunks)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1 hunks)
  • packages/jumpstarter-driver-ssh/pyproject.toml (1 hunks)
  • packages/jumpstarter/jumpstarter/client/client.py (1 hunks)
  • pyproject.toml (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-01-29T11:52:43.554Z
Learnt from: bennyz
PR: jumpstarter-dev/jumpstarter#241
File: packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/client.py:52-60
Timestamp: 2025-01-29T11:52:43.554Z
Learning: The TFTP driver (packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver.py) handles all low-level concerns like path validation, error handling, and checksum computation. The client (packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/client.py) should remain simple as it delegates these responsibilities to the driver.

Applied to files:

  • pyproject.toml
🧬 Code graph analysis (4)
packages/jumpstarter/jumpstarter/client/client.py (1)
packages/jumpstarter/jumpstarter/common/grpc.py (1)
  • configure_grpc_env (71-78)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
packages/jumpstarter/jumpstarter/driver/base.py (1)
  • Driver (56-269)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (36-46)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (4)
packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py (1)
  • TcpNetwork (88-121)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)
  • SSHWrapper (8-33)
  • client (22-23)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (2)
  • SSHWrapperClient (50-245)
  • run_ssh_command (71-100)
packages/jumpstarter/jumpstarter/common/exceptions.py (1)
  • FileNotFoundError (66-69)
🪛 GitHub Check: ruff
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py

[failure] 212-212: Ruff (W293)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:212:1: W293 Blank line contains whitespace


[failure] 204-204: Ruff (W291)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:204:90: W291 Trailing whitespace


[failure] 191-191: Ruff (W291)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:191:72: W291 Trailing whitespace


[failure] 170-170: Ruff (E501)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:170:121: E501 Line too long (219 > 120)


[failure] 156-156: Ruff (W293)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:156:1: W293 Blank line contains whitespace


[failure] 147-147: Ruff (W293)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:147:1: W293 Blank line contains whitespace


[failure] 139-139: Ruff (W291)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:139:90: W291 Trailing whitespace


[failure] 130-130: Ruff (W293)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:130:1: W293 Blank line contains whitespace


[failure] 106-106: Ruff (W293)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:106:1: W293 Blank line contains whitespace


[failure] 102-102: Ruff (C901)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:102:9: C901 _run_ssh_local is too complex (29 > 10)

🪛 GitHub Actions: Lint
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py

[error] 102-102: ruff check failed: C901 '_run_ssh_local' is too complex (29 > 10)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: e2e
  • GitHub Check: build
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
🔇 Additional comments (10)
packages/jumpstarter/jumpstarter/client/client.py (1)

12-19: LGTM: configuring gRPC before channel setup.

Thanks for front-loading the environment configuration so every channel gets the intended log hygiene without relying on callers.

pyproject.toml (1)

31-31: Workspace source entry LGTM

Adding jumpstarter-driver-ssh to the UV workspace sources is correct and aligns with the new package.

packages/jumpstarter-driver-ssh/pyproject.toml (1)

11-17: Runtime deps look minimal and appropriate

anyio/click/jumpstarter/composite/network are the right deps for this client/driver split. No issues.

If jumpstarter-driver-composite is truly required at runtime (not just for tests/CLI wiring), keep it here; otherwise consider moving it to dev to reduce install surface.

docs/source/reference/package-apis/drivers/index.md (1)

83-84: Docs catalog update LGTM

SSH entry and toctree inclusion are consistent and correctly categorized.

Also applies to: 105-105

packages/jumpstarter-driver-ssh/examples/exporter.yaml (1)

9-19: Example structure looks correct

Exporter -> ssh -> tcp layout matches the driver’s requirement for a tcp child. Defaults are sensible.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)

18-20: Validation for required child is good

Enforcing presence of tcp child at init time prevents misconfiguration later.


11-13: Default includes LogLevel=ERROR — good

Suppressing SSH noise by default is consistent with CLI UX; docs should match.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (3)

68-96: Test intent is solid

Covers default username injection, host placement, and command preservation. No changes requested.


157-195: Port handling via forward adapter covered well

Good coverage for -p injection and host placement.


228-244: Error handling test is correct

Returning 127 on missing SSH binary matches common conventions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

173-207: Handle more SSH options (-J, -t/-tt, -4/-6, -vvv, long options) to avoid misclassifying them as remote commands

The rigid whitelist misses valid flags, causing options like -J, -tt, -vvv, -4/-6 to be treated as the start of the remote command. Generalize parsing.

Apply these diffs:

Import regex support:

-import shlex
+import shlex
+import re

Generalize option parsing:

     def _separate_ssh_options_and_command_args(self, processed_args):
         """Separate SSH options from command arguments"""
-        ssh_options = []
-        command_args = []
-        i = 0
+        ssh_options = []
+        command_args = []
+        # Options that take a value as the next token
+        opts_with_value = {
+            "-l", "-p", "-o", "-i", "-F", "-c", "-m", "-D", "-E", "-L",
+            "-O", "-P", "-Q", "-R", "-S", "-W", "-w", "-J", "-b", "-B",
+        }
+        no_value_flags = {
+            "-4", "-6", "-t", "-tt", "-A", "-a", "-C", "-f", "-G", "-g",
+            "-K", "-k", "-M", "-N", "-n", "-q", "-T", "-X", "-Y", "-v",
+        }
+        i = 0
         while i < len(processed_args):
             arg = processed_args[i]
-            if arg.startswith('-'):
-                # Check if it's a known SSH option
-                if arg in [
-                    '-l', '-p', '-o', '-i', '-F', '-c', '-m', '-v', '-q', '-X', '-Y',
-                    '-N', '-T', '-f', '-n', '-s', '-A', '-B', '-C', '-D', '-E', '-G',
-                    '-K', '-L', '-M', '-O', '-P', '-Q', '-R', '-S', '-W', '-w'
-                ]:
-                    # This is an SSH option
-                    ssh_options.append(arg)
-                    # If this option takes a value, add the next argument too
-                    if i + 1 < len(processed_args) and not processed_args[i + 1].startswith('-'):
-                        ssh_options.append(processed_args[i + 1])
-                        i += 1
-                else:
-                    # This is a command argument - everything from here on is part of the command
-                    command_args = processed_args[i:]
-                    break
+            if arg.startswith("-"):
+                # Accept long options and clusters like -vvv
+                if arg.startswith("--") or re.fullmatch(r"-v+", arg) or arg in no_value_flags or arg in opts_with_value:
+                    ssh_options.append(arg)
+                    if arg in opts_with_value and i + 1 < len(processed_args):
+                        ssh_options.append(processed_args[i + 1])
+                        i += 1
+                    i += 1
+                    continue
+                # Unknown dash-prefixed token: still treat as an option for robustness
+                ssh_options.append(arg)
+                i += 1
+                continue
             else:
                 # This is a command argument - everything from here on is part of the command
                 command_args = processed_args[i:]
                 break
             i += 1
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)

373-401: Add coverage for missing-but-valid SSH flags (-J, -tt, -vvv, -4/-6)

Current parsing likely misclassifies these as remote command tokens. Add tests to lock desired behavior and guide parser updates.

Example additions:

def test_ssh_with_proxyjump_and_verbose(serve, SSHWrapper, TcpNetwork, mocker):
    instance = SSHWrapper(children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, default_username="u")
    with serve(instance) as client, mocker.patch("subprocess.run") as run, \
         patch("jumpstarter_driver_ssh.client.TcpPortforwardAdapter", asynccontextmanager(lambda **_: (lambda: (_ for _ in ()).throw(RuntimeError("stub"))))):
        run.return_value = MagicMock(returncode=0)
        result = client.run_ssh_command(False, ["-J", "bastion", "-vvv", "hostname"])
        assert result == 0
        argv = run.call_args[0][0]
        assert "-J" in argv and "bastion" in argv
        assert any(a.startswith("-vv") for a in argv)
        assert "hostname" in argv

def test_ssh_with_tty_and_ipv6_flag(mocker):
    instance = SSHWrapper(children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, default_username="")
    with serve(instance) as client, mocker.patch("subprocess.run") as run:
        run.return_value = MagicMock(returncode=0)
        result = client.run_ssh_command(False, ["-tt", "-6", "whoami"])
        assert result == 0
        argv = run.call_args[0][0]
        assert "-tt" in argv and "-6" in argv
        assert "whoami" in argv

71-99: Optional: assert exact argv ordering to harden behavior

Tests only assert presence. Consider asserting ordering (ssh options, host, then remote command) to catch regressions in assembly semantics.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8d078e6 and 1eec425.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • docs/source/reference/package-apis/drivers/index.md (2 hunks)
  • docs/source/reference/package-apis/drivers/ssh.md (1 hunks)
  • packages/jumpstarter-cli/jumpstarter_cli/jmp.py (1 hunks)
  • packages/jumpstarter-driver-ssh/README.md (1 hunks)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1 hunks)
  • packages/jumpstarter-driver-ssh/pyproject.toml (1 hunks)
  • packages/jumpstarter/jumpstarter/client/client.py (1 hunks)
  • pyproject.toml (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • docs/source/reference/package-apis/drivers/ssh.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • pyproject.toml
  • docs/source/reference/package-apis/drivers/index.md
  • packages/jumpstarter-driver-ssh/pyproject.toml
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml
  • packages/jumpstarter-driver-ssh/README.md
  • packages/jumpstarter/jumpstarter/client/client.py
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py
🧰 Additional context used
🧬 Code graph analysis (3)
packages/jumpstarter-cli/jumpstarter_cli/jmp.py (1)
packages/jumpstarter/jumpstarter/common/grpc.py (1)
  • configure_grpc_env (71-78)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (4)
packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py (1)
  • TcpNetwork (88-121)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)
  • client (22-23)
  • SSHWrapper (8-33)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (2)
  • SSHWrapperClient (36-259)
  • run_ssh_command (57-86)
packages/jumpstarter/jumpstarter/common/exceptions.py (1)
  • FileNotFoundError (66-69)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (36-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: e2e
🔇 Additional comments (6)
packages/jumpstarter-cli/jumpstarter_cli/jmp.py (1)

17-21: Great call configuring gRPC on import

Initializing the gRPC environment before the Click group is created keeps the CLI quiet and avoids the SSL chatter we were seeing. The helper is idempotent, so doing it at import time is safe.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)

74-76: Guard against recursive fallback loops and preserve original error context

If direct mode fails with a non-address issue, recursive fallback might mask root cause. Consider logging exc_info and ensuring the fallback path can’t re-enter here.

Minimal tweak:

-            except (DriverMethodNotImplemented, ValueError) as e:
-                self.logger.error(f"Direct address connection failed ({e}), falling back to SSH port forwarding")
+            except (DriverMethodNotImplemented, ValueError) as e:
+                self.logger.error("Direct address connection failed, falling back to SSH port forwarding", exc_info=True)
                 return self.run_ssh_command(False, args)

Please confirm the port-forward path cannot trigger the same exception cycle.


78-86: Fix: TcpPortforwardAdapter is async; current with will crash

TcpPortforwardAdapter is an async context manager. Using with raises TypeError and the port-forward never opens. Drive it via the portal or wrap it with a sync context manager.

Apply this diff to use a sync wrapper:

-            # Use SSH port forwarding (default behavior)
-            self.logger.debug("Using SSH port forwarding for SSH connection")
-            with TcpPortforwardAdapter(
-                client=self.tcp,
-            ) as addr:
-                host = addr[0]
-                port = addr[1]
-                self.logger.debug(f"SSH port forward established - host: {host}, port: {port}")
-                return self._run_ssh_local(host, port, ssh_command, default_username, args)
+            # Use SSH port forwarding (default behavior)
+            self.logger.debug("Using SSH port forwarding for SSH connection")
+            with self._portforward_sync() as (host, port):
+                self.logger.debug(f"SSH port forward established - host: {host}, port: {port}")
+                return self._run_ssh_local(host, port, ssh_command, default_username, args)

Add this helper to the class (outside the selected range):

from contextlib import contextmanager

@contextmanager
def _portforward_sync(self):
    # Enter the async context on the portal's loop and keep it open until __exit__
    cm_holder = {}

    def _enter():
        async def _runner():
            cm = TcpPortforwardAdapter(client=self.tcp)
            addr = await cm.__aenter__()
            cm_holder["cm"] = cm
            return addr
        return self.portal.call_async(_runner)
    addr = self.portal.call(_enter)

    try:
        yield addr
    finally:
        def _exit():
            async def _runner():
                cm = cm_holder.get("cm")
                if cm:
                    await cm.__aexit__(None, None, None)
            return self.portal.call_async(_runner)
        self.portal.call(_exit)

240-246: Replace/remove placeholder hostname when injecting the real host

If the user typed a placeholder host (e.g., “device”), we must drop it when appending the real host to avoid running ssh <real> device ....

Apply this diff:

-        # Add hostname before command arguments
-        if host and not hostname_replaced:
-            ssh_args.append(host)
-
-        # Add command arguments
-        ssh_args.extend(command_args)
+        # Add hostname before command arguments
+        if host and not hostname_replaced:
+            # Drop a leading placeholder host token (plain hostname)
+            if command_args and not command_args[0].startswith("-") and not any(c in command_args[0] for c in ("@", ":", "/")):
+                command_args = command_args[1:]
+            ssh_args.append(host)
+
+        # Add command arguments
+        ssh_args.extend(command_args)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)

171-175: Tests mock an async context manager as sync; update to async-aware mocks

These tests patch TcpPortforwardAdapter with sync __enter__/__exit__, cementing the incorrect API and will fail once the client fixes the adapter usage.

Use an asynccontextmanager stub:

from contextlib import asynccontextmanager

@asynccontextmanager
async def fake_adapter(*args, **kwargs):
    yield ("127.0.0.1", 2222)  # or desired host, port

with patch("jumpstarter_driver_ssh.client.TcpPortforwardAdapter", fake_adapter):
    ...

Also applies to: 415-418, 486-489, 521-524, 556-559


312-342: Verify expectation: should “hostname” be preserved here?

Input is ["-l", "explicituser", "otheruser@hostname"] (no remote command). Expect the placeholder host (“hostname”) to be replaced by the real forwarded host, not preserved as a command arg. Please confirm and adjust assertions accordingly.

@mangelajo mangelajo force-pushed the ssh-wrap branch 2 times, most recently from fc2cdbf to abcfa6d Compare September 24, 2025 22:37
Copy link
Member Author

@mangelajo mangelajo left a comment

Choose a reason for hiding this comment

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

Some tests need fixing.

@mangelajo mangelajo force-pushed the ssh-wrap branch 2 times, most recently from 91be14b to 23dc13a Compare September 25, 2025 06:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1eec425 and 23dc13a.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • docs/source/reference/package-apis/drivers/index.md (2 hunks)
  • docs/source/reference/package-apis/drivers/ssh.md (1 hunks)
  • packages/jumpstarter-driver-ssh/README.md (1 hunks)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1 hunks)
  • packages/jumpstarter-driver-ssh/pyproject.toml (1 hunks)
  • pyproject.toml (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • docs/source/reference/package-apis/drivers/ssh.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml
  • packages/jumpstarter-driver-ssh/README.md
  • pyproject.toml
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
  • docs/source/reference/package-apis/drivers/index.md
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T05:52:27.137Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:0-0
Timestamp: 2025-09-25T05:52:27.137Z
Learning: In the SSHWrapper driver's client.py, hostname replacement should only occur for "userhostname" format arguments that get preprocessed into "-l user hostname". Regular hostname arguments (like "device" in SSH commands) should be passed through unchanged to allow the wrapper to handle connections internally.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (36-46)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
packages/jumpstarter/jumpstarter/driver/base.py (1)
  • Driver (56-269)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
🔇 Additional comments (2)
packages/jumpstarter-driver-ssh/pyproject.toml (1)

23-25: Correct the package source URL.

The source_archive still targets the placeholder jumpstarter-dev/repo, so consumers will follow a broken link. Point it to the actual Jumpstarter repository.

 [tool.hatch.metadata.hooks.vcs.urls]
 Homepage = "https://jumpstarter.dev"
-source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip"
+source_archive = "https://github.com/jumpstarter-dev/jumpstarter/archive/{commit_hash}.zip"
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)

18-20: Nice early configuration guard

Checking for the required tcp child up front gives users immediate feedback on misconfigurations. Nicely done.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (3)

49-65: Assert on the executed argv, not just membership.

Right now we only check that mock_run was called and that a few tokens exist. Verifying the exact argument order (e.g., using assert mock_run.call_args[0][0] == [...]) would protect against regressions in how we build the SSH command. Please tighten these assertions.


323-337: Invalid user@host@extra input should probably raise.

Silently coercing this string into -l user host@extra is surprising. Consider asserting that we bubble an error (or leave the arg untouched) so callers notice malformed hosts instead of running unexpected commands.


381-404: Use contextlib when faking TcpPortforwardAdapter.

The adapter is supposed to be a proper (a)sync context manager. Returning a tuple directly bypasses that protocol. Wrap your stub in contextlib.contextmanager so we still exercise __enter__/__exit__.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)

11-19: Call the parent __post_init__ unconditionally.

The base Driver already defines __post_init__; the hasattr check is unnecessary and risks skipping initialization if it ever gets shadowed. Just invoke super().__post_init__() directly.


18-19: Include driver name in the error to aid debugging.

When the tcp child is missing, surfacing the driver name (SSHWrapper) in the exception message helps operators trace config issues. Consider extending the message accordingly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 23dc13a and ad60ee5.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • docs/source/reference/package-apis/drivers/index.md (2 hunks)
  • docs/source/reference/package-apis/drivers/ssh.md (1 hunks)
  • packages/jumpstarter-driver-ssh/README.md (1 hunks)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1 hunks)
  • packages/jumpstarter-driver-ssh/pyproject.toml (1 hunks)
  • pyproject.toml (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • docs/source/reference/package-apis/drivers/ssh.md
  • packages/jumpstarter-driver-ssh/README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/jumpstarter-driver-ssh/pyproject.toml
  • docs/source/reference/package-apis/drivers/index.md
  • pyproject.toml
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T05:52:27.137Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:0-0
Timestamp: 2025-09-25T05:52:27.137Z
Learning: In the SSHWrapper driver's client.py, hostname replacement should only occur for "userhostname" format arguments that get preprocessed into "-l user hostname". Regular hostname arguments (like "device" in SSH commands) should be passed through unchanged to allow the wrapper to handle connections internally.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
🧬 Code graph analysis (3)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (4)
packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py (1)
  • TcpNetwork (88-121)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)
  • SSHWrapper (8-33)
  • client (22-23)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)
  • run (65-94)
packages/jumpstarter/jumpstarter/common/exceptions.py (1)
  • FileNotFoundError (66-69)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
packages/jumpstarter/jumpstarter/driver/base.py (1)
  • Driver (56-269)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (36-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: e2e
🔇 Additional comments (6)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (4)

522-546: Guard against leaking placeholder tokens into argv.

Even for “special formats” we should ensure the resolved host replaces the placeholder when the username came from user@host. Otherwise we invoke ssh … host hostname:port …, which is not a valid remote command. Please tighten the test accordingly.


407-439: Same as above—remove the placeholder host.

The expectation that both "10.0.0.5" and "hostname" remain contradicts SSH semantics. Please assert that the placeholder disappears after replacement so the final argv is valid.


441-474: Ditto for the placeholder host.

Keeping "hostname" after adding "172.16.0.10" causes the wrapper to run hostname as the remote command. Update the test to make sure we emit only the real host plus any user-specified commands.


493-507: Drop the placeholder once the tunnel host is known.

Here again we should expect only "192.168.0.1" (plus any commands) in the final argv; otherwise SSH interprets "hostname" as the command to run. Please correct the test to reflect that.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (2)

181-214: Short options like -p2222 and -vv are misparsed.

Because we compare tokens against a literal whitelist, merged flags (-p2222, -vvv, -J, --ProxyCommand, etc.) drop into command_args. The resulting argv becomes ssh … <host> -p2222 …, so SSH misinterprets the option as a remote command. Please switch to a generic option parser (respecting --) that consumes short/long forms without hardcoding every flag.


248-253: Preserve placeholder removal when appending the real host.

If hostname_replaced is still False but the first command arg is just the placeholder host, we need to pop it before appending the resolved host; otherwise we execute ssh … host placeholder … and SSH treats placeholder as a remote command. Drop or overwrite that arg before extending.

@mangelajo
Copy link
Member Author

Stop parsing the user@host and take anything after non-ssh flags as the command start. otherwise things like...

Also verify that cases like this will work : j ssh ls -l root -la

@mangelajo mangelajo force-pushed the ssh-wrap branch 2 times, most recently from 8550eb1 to bcdc5c2 Compare September 25, 2025 20:42
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 (6)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)

74-87: Ordering causes double parsing of args; minor overhead.

You call _separate_ssh_options_and_command_args twice. Cache once and pass through to helpers.

-        ssh_args = self._build_ssh_command_args(ssh_command, port, default_username, args)
-        # Separate SSH options from command arguments
-        ssh_options, command_args = self._separate_ssh_options_and_command_args(args)
+        ssh_options, command_args = self._separate_ssh_options_and_command_args(args)
+        ssh_args = self._build_ssh_command_args(ssh_command, port, default_username, args)

And change _build_ssh_command_args to accept precomputed ssh_options to avoid recomputation.


93-107: Username detection can misread malformed “-l” usage.

If users pass “-l” as the last token (no value), we still suppress default username injection. Prefer stricter check for “-l ”.

-        has_user_flag = any(
-            ssh_options[i] == "-l" and i + 1 < len(ssh_options)
-            for i in range(len(ssh_options))
-        )
+        has_user_flag = any(
+            ssh_options[i] == "-l" and i + 1 < len(ssh_options) and not ssh_options[i + 1].startswith("-")
+            for i in range(len(ssh_options))
+        )

165-169: Confirm intended behavior: first positional token becomes remote command, not host.

Given wrapper design, “j ssh hostname” runs remote command “hostname” against the tunneled host. If that’s intentional, consider a short note in the README to prevent confusion for users accustomed to “ssh host [cmd]”.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (3)

138-142: Stub the adapter with a lightweight contextmanager for clarity.

Using MagicMock works, but a small contextmanager improves readability and mirrors the contract.

-            with patch('jumpstarter_driver_ssh.client.TcpPortforwardAdapter') as mock_adapter:
-                mock_adapter.return_value.__enter__.return_value = ("127.0.0.1", 2222)
-                mock_adapter.return_value.__exit__.return_value = None
+            from contextlib import contextmanager
+            @contextmanager
+            def _fake_adapter(**_):
+                yield ("127.0.0.1", 2222)
+            with patch('jumpstarter_driver_ssh.client.TcpPortforwardAdapter', _fake_adapter):
                 # Test SSH command with custom port
                 result = client.run(False, ["hostname"])

311-350: Add a test for the reported edge case “ls -l root -la”.

You cover a similar case; add this exact sequence to lock behavior.

I can add a parametric test invoking client.run(False, ["ls", "-l", "root", "-la"]) and asserting: [-l testuser] before host, host present, and command args preserved after host.


251-281: Reconfirm expectation: unknown -flags start the command.

This test codifies the desired behavior; keep it after parser tweaks. Also add a case for “-vvv” to ensure it remains an SSH option, not a command arg.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ad60ee5 and 902ba49.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • docs/source/reference/package-apis/drivers/index.md (2 hunks)
  • docs/source/reference/package-apis/drivers/ssh.md (1 hunks)
  • packages/jumpstarter-driver-ssh/README.md (1 hunks)
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1 hunks)
  • packages/jumpstarter-driver-ssh/pyproject.toml (1 hunks)
  • pyproject.toml (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • packages/jumpstarter-driver-ssh/README.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • docs/source/reference/package-apis/drivers/index.md
  • packages/jumpstarter-driver-ssh/pyproject.toml
  • packages/jumpstarter-driver-ssh/examples/exporter.yaml
  • pyproject.toml
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-25T10:48:56.377Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:71-94
Timestamp: 2025-09-25T10:48:56.377Z
Learning: TcpPortforwardAdapter has a decorator that allows both async and sync callers, so it can be used with both `with` (synchronous) and `async with` (asynchronous) context managers.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
📚 Learning: 2025-09-25T05:52:27.157Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:0-0
Timestamp: 2025-09-25T05:52:27.157Z
Learning: In the SSHWrapper driver's client.py, hostname replacement should only occur for "userhostname" format arguments that get preprocessed into "-l user hostname". Regular hostname arguments (like "device" in SSH commands) should be passed through unchanged to allow the wrapper to handle connections internally.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (4)
packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py (1)
  • TcpNetwork (88-121)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (2)
  • SSHWrapper (8-33)
  • client (22-23)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)
  • run (43-72)
packages/jumpstarter/jumpstarter/common/exceptions.py (1)
  • FileNotFoundError (66-69)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (36-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: e2e
🔇 Additional comments (3)
docs/source/reference/package-apis/drivers/ssh.md (1)

1-1: Replace the bare README path with an include or link

Leaving ../../../../../packages/jumpstarter-driver-ssh/README.md as plain text renders the raw path instead of surfacing the README content or a navigable link. Wrap it in a MyST include (with the relative options) or convert it into a Markdown link so the page renders correctly.

-../../../../../packages/jumpstarter-driver-ssh/README.md
+```{include} ../../../../../packages/jumpstarter-driver-ssh/README.md
+:relative-docs: true
+:relative-images: true
+```
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (2)

66-72: LGTM: Sync context for TcpPortforwardAdapter.

Per project note, adapter supports both sync and async usage; this is acceptable.


112-157: Option parsing misclassifies common SSH flags (-vv/-vvv, -tt) and misses --/combined -o= forms.

Today, “-vv”/“-vvv”/“-tt” become remote command args and “-oStrictHostKeyChecking=no” is not recognized, breaking normal SSH usage. Also, “--” should stop option parsing. Keep your desired behavior of stopping at the first non-SSH flag, but recognize these cases.

Apply this diff:

     def _separate_ssh_options_and_command_args(self, args):
         """Separate SSH options from command arguments"""
         # SSH flags that do not expect a parameter (simple flags)
         ssh_flags_no_param = {
-            '-4', '-6', '-A', '-a', '-C', '-f', '-G', '-g', '-K', '-k', '-M', '-N',
-            '-n', '-q', '-s', '-T', '-t', '-V', '-v', '-X', '-x', '-Y', '-y'
+            '-4', '-6', '-A', '-a', '-C', '-f', '-G', '-g', '-K', '-k', '-M', '-N',
+            '-n', '-q', '-s', '-T', '-t', '-V', '-v', '-X', '-x', '-Y', '-y',
+            # Common repeated short flags
+            '-vv', '-vvv', '-tt',
         }
 
         # SSH flags that do expect a parameter
         ssh_flags_with_param = {
             '-B', '-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l',
             '-m', '-O', '-o', '-P', '-p', '-Q', '-R', '-S', '-W', '-w'
         }
 
         ssh_options = []
         command_args = []
         i = 0
         while i < len(args):
             arg = args[i]
+            # End of options sentinel
+            if arg == "--":
+                command_args = args[i + 1:]
+                break
             if arg.startswith('-'):
                 # Check if it's a known SSH option
-                if arg in ssh_flags_no_param:
+                # Support combined -oKey=Value form
+                if arg.startswith('-o') and arg != '-o':
+                    ssh_options.append(arg)
+                elif arg in ssh_flags_no_param:
                     # This is a simple SSH flag without parameter
                     ssh_options.append(arg)
                 elif arg in ssh_flags_with_param:
                     # This is an SSH flag that expects a parameter
                     ssh_options.append(arg)
                     # If this option takes a value, add the next argument too
                     if i + 1 < len(args) and not args[i + 1].startswith('-'):
                         ssh_options.append(args[i + 1])
                         i += 1
                 else:
                     # This is a command argument - everything from here on is part of the command
                     command_args = args[i:]
                     break
             else:
                 # This is a command argument - everything from here on is part of the command
                 command_args = args[i:]
                 break
             i += 1

This preserves your intent (“unknown SSH-like flags trigger command mode”) while fixing common SSH flows. Based on learnings

mangelajo and others added 2 commits September 29, 2025 10:56
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

174-184: Optional: capture return code and emit clearer errors

Current subprocess.run is fine. If desired, use check=False and text mode with env inheritance explicitly for clearer diagnostics; not blocking.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 902ba49 and 29c2571.

📒 Files selected for processing (1)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-25T10:48:56.377Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:71-94
Timestamp: 2025-09-25T10:48:56.377Z
Learning: TcpPortforwardAdapter has a decorator that allows both async and sync callers, so it can be used with both `with` (synchronous) and `async with` (asynchronous) context managers.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
📚 Learning: 2025-09-25T05:52:27.157Z
Learnt from: mangelajo
PR: jumpstarter-dev/jumpstarter#647
File: packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py:0-0
Timestamp: 2025-09-25T05:52:27.157Z
Learning: In the SSHWrapper driver's client.py, hostname replacement should only occur for "userhostname" format arguments that get preprocessed into "-l user hostname". Regular hostname arguments (like "device" in SSH commands) should be passed through unchanged to allow the wrapper to handle connections internally.

Applied to files:

  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
🧬 Code graph analysis (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (22-23)
packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py (1)
  • CompositeClient (28-60)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
🔇 Additional comments (3)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)

21-31: CLI shape looks right for the “don’t parse user@host” goal

ignore_unknown_options + variadic args ensures cases like “j ssh ls -l root -la” are treated as a remote command, not SSH options or user/host. Good.

Please add/confirm a test for:

  • j ssh ls -l root -la → ssh -- ls -l root -la (i.e., no attempt to parse “root” as user/host).

66-72: Sync use of TcpPortforwardAdapter is acceptable here

Given the decorator that supports both sync and async context managers, this “with … as addr” usage won’t raise TypeError and keeps the tunnel open while ssh runs. Based on learnings.


112-157: Fix SSH option parsing: support -vvv, -p2222, -L8080:..., -oUser=..., and “--” sentinel

The current whitelist approach misclassifies common flags as command args (e.g., -vvv, -p2222, -L8080:..., -Jjump, -oUser=foo). This breaks typical SSH usage.

Apply this diff to replace the parser with a generic, SSH-friendly one that:

  • treats any leading “-” token as an SSH option until the first non-option or “--”,
  • recognizes inline values for options that take a value (-p2222, -L8080:host:80, -Whost:port, -Juser@host:port, -oUser=foo),
  • accepts grouped no-param flags like -vvv or -NT,
  • stops at “--” and does not pass it to the remote command.
@@
-    def _separate_ssh_options_and_command_args(self, args):
-        """Separate SSH options from command arguments"""
-        # SSH flags that do not expect a parameter (simple flags)
-        ssh_flags_no_param = {
-            '-4', '-6', '-A', '-a', '-C', '-f', '-G', '-g', '-K', '-k', '-M', '-N',
-            '-n', '-q', '-s', '-T', '-t', '-V', '-v', '-X', '-x', '-Y', '-y'
-        }
-
-        # SSH flags that do expect a parameter
-        ssh_flags_with_param = {
-            '-B', '-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l',
-            '-m', '-O', '-o', '-P', '-p', '-Q', '-R', '-S', '-W', '-w'
-        }
-
-        ssh_options = []
-        command_args = []
-        i = 0
-        while i < len(args):
-            arg = args[i]
-            if arg.startswith('-'):
-                # Check if it's a known SSH option
-                if arg in ssh_flags_no_param:
-                    # This is a simple SSH flag without parameter
-                    ssh_options.append(arg)
-                elif arg in ssh_flags_with_param:
-                    # This is an SSH flag that expects a parameter
-                    ssh_options.append(arg)
-                    # If this option takes a value, add the next argument too
-                    if i + 1 < len(args) and not args[i + 1].startswith('-'):
-                        ssh_options.append(args[i + 1])
-                        i += 1
-                else:
-                    # This is a command argument - everything from here on is part of the command
-                    command_args = args[i:]
-                    break
-            else:
-                # This is a command argument - everything from here on is part of the command
-                command_args = args[i:]
-                break
-            i += 1
-
-        # Debug output
-        self.logger.debug(f"SSH options: {ssh_options}")
-        self.logger.debug(f"Command args: {command_args}")
-        return ssh_options, command_args
+    def _separate_ssh_options_and_command_args(self, args):
+        """Separate SSH options from command arguments, supporting inline/clustered flags."""
+        # Options that take a value (support both split and inline forms, e.g. -p 2222 or -p2222)
+        with_value = {
+            "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J",
+            "-L", "-l", "-m", "-O", "-o", "-P", "-p", "-Q", "-R", "-S", "-W", "-w",
+        }
+        # Options without value that can be clustered (e.g., -vvv, -NT)
+        no_value = {
+            "-4", "-6", "-A", "-a", "-C", "-f", "-G", "-g", "-K", "-k", "-M", "-N",
+            "-n", "-q", "-s", "-T", "-t", "-V", "-v", "-X", "-x", "-Y", "-y",
+        }
+
+        ssh_options: list[str] = []
+        command_args: list[str] = []
+
+        i = 0
+        while i < len(args):
+            arg = args[i]
+
+            # Stop parsing options at explicit sentinel
+            if arg == "--":
+                command_args = args[i + 1 :]
+                break
+
+            # First non-option starts the remote command
+            if not arg.startswith("-") or arg == "-":
+                command_args = args[i:]
+                break
+
+            # Pass through any double-dash options as-is (ssh may reject; user’s choice)
+            if arg.startswith("--"):
+                ssh_options.append(arg)
+                i += 1
+                continue
+
+            # Handle inline value short options: -p2222, -L8080:host:80, -Whost:port, -Juser@host:port, -oUser=val
+            key = arg[:2]
+            rest = arg[2:]
+            if key in with_value and rest:
+                ssh_options.append(arg)  # keep combined token intact
+                i += 1
+                continue
+
+            # Handle clusters of no-value flags, e.g., -vvv or -NT
+            if len(arg) > 2 and all(f"-{ch}" in no_value for ch in arg[1:]):
+                ssh_options.append(arg)
+                i += 1
+                continue
+
+            # Fallback: single short option, possibly followed by a separate value
+            ssh_options.append(key if key in with_value else arg)
+            if key in with_value and not rest:
+                # Consume next token as the value (even if it starts with '-'; ssh allows values like '-')
+                if i + 1 < len(args):
+                    ssh_options.append(args[i + 1])
+                    i += 1
+            i += 1
+
+        self.logger.debug(f"SSH options: {ssh_options}")
+        self.logger.debug(f"Command args: {command_args}")
+        return ssh_options, command_args

Comment on lines +79 to +86
# Separate SSH options from command arguments
ssh_options, command_args = self._separate_ssh_options_and_command_args(args)

# Build final SSH command
ssh_args = self._build_final_ssh_command(ssh_args, ssh_options, host, command_args)

# Execute the command
return self._execute_ssh_command(ssh_args)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Ensure wrapper-enforced port cannot be overridden by user “-p”

If the wrapper supplies a port (direct or via forward), a user “-p …” in args currently wins because it’s appended later. Normalize options so the enforced port is last and unique.

Apply this diff to move port handling into the final assembly and drop user-provided “-p” variants before appending the enforced port:

@@ def _run_ssh_local(self, host, port, ssh_command, default_username, args):
-        # Build final SSH command
-        ssh_args = self._build_final_ssh_command(ssh_args, ssh_options, host, command_args)
+        # Build final SSH command
+        ssh_args = self._build_final_ssh_command(ssh_args, ssh_options, host, port, command_args)
@@
-    def _build_final_ssh_command(self, ssh_args, ssh_options, host, command_args):
+    def _build_final_ssh_command(self, ssh_args, ssh_options, host, port, command_args):
         """Build the final SSH command with all components"""
-        # Add SSH options
-        ssh_args.extend(ssh_options)
+        # Normalize port (-p) so the wrapper-provided port (if any) wins
+        ssh_args.extend(self._normalize_port_option(ssh_options, port))
@@
         # Add hostname before command arguments
         if host:
             ssh_args.append(host)
@@
         self.logger.debug(f"Running SSH command: {ssh_args}")
         return ssh_args
+
+    def _normalize_port_option(self, ssh_options, port):
+        """Remove user -p overrides and append enforced port last (if provided)."""
+        normalized: list[str] = []
+        i = 0
+        while i < len(ssh_options):
+            tok = ssh_options[i]
+            # Drop -p VALUE
+            if tok == "-p":
+                i += 2 if i + 1 < len(ssh_options) else 1
+                continue
+            # Drop inline -pVALUE
+            if tok.startswith("-p") and tok != "-p":
+                i += 1
+                continue
+            normalized.append(tok)
+            i += 1
+        if port and port != 22:
+            normalized.extend(["-p", str(port)])
+        return normalized

Also applies to: 159-173

🤖 Prompt for AI Agents
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py around lines
79-86 (also applies to 159-173): user-supplied “-p”/“--port” currently wins
because it’s appended after ssh_options; remove any user-provided port flags
from the combined args during final assembly, then append the wrapper-enforced
port option last (ensuring only one port flag exists) — move port handling into
_build_final_ssh_command so it normalizes duplicates (strip "-p" and "--port"
and their values from args/ssh_options), and finally append the enforced port as
the last unique option before executing.

Comment on lines +88 to +109
def _build_ssh_command_args(self, ssh_command, port, default_username, args):
"""Build initial SSH command arguments"""
# Split the SSH command into individual arguments
ssh_args = shlex.split(ssh_command)

# Add port if specified
if port and port != 22:
ssh_args.extend(["-p", str(port)])

# Check if user already provided a username with -l flag in SSH options only
# We need to separate SSH options from command args first to avoid false positives
ssh_options, _ = self._separate_ssh_options_and_command_args(args)
has_user_flag = any(
ssh_options[i] == "-l" and i + 1 < len(ssh_options)
for i in range(len(ssh_options))
)

# Add default username if no -l flag provided and we have a default
if not has_user_flag and default_username:
ssh_args.extend(["-l", default_username])

return ssh_args
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Detect username via “-luser” and “-o User=…” too; don’t miss inline forms

Only checking for a standalone “-l” misses common forms (“-lroot”, “-oUser=alice”). That can incorrectly inject a default username.

Apply this diff to improve detection and avoid adding a default user when user is already specified:

@@ def _build_ssh_command_args(self, ssh_command, port, default_username, args):
-        # Add port if specified
-        if port and port != 22:
-            ssh_args.extend(["-p", str(port)])
-
         # Check if user already provided a username with -l flag in SSH options only
         # We need to separate SSH options from command args first to avoid false positives
         ssh_options, _ = self._separate_ssh_options_and_command_args(args)
-        has_user_flag = any(
-            ssh_options[i] == "-l" and i + 1 < len(ssh_options)
-            for i in range(len(ssh_options))
-        )
+        has_user_flag = False
+        i = 0
+        while i < len(ssh_options):
+            tok = ssh_options[i]
+            # -l user  or inline -luser
+            if tok == "-l" and i + 1 < len(ssh_options):
+                has_user_flag = True
+                break
+            if tok.startswith("-l") and tok != "-l":
+                has_user_flag = True
+                break
+            # -o User=alice  or inline -oUser=alice
+            if tok == "-o" and i + 1 < len(ssh_options):
+                val = str(ssh_options[i + 1]).lower()
+                if val.startswith("user=") or val.startswith("user "):
+                    has_user_flag = True
+                    break
+                i += 1  # skip value
+            elif tok.startswith("-o") and len(tok) > 2:
+                oval = tok[2:].lower()
+                if oval.startswith("user=") or oval.startswith("user "):
+                    has_user_flag = True
+                    break
+            i += 1
@@
         if not has_user_flag and default_username:
             ssh_args.extend(["-l", default_username])
 
         return ssh_args

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py around lines
88 to 109, the current detection for an already-specified username only checks
for a standalone "-l" flag and misses inline forms like "-lroot" and -o options
such as "-oUser=alice" or "-o User=alice"; update the detection to treat any
ssh_options entry that starts with "-l" (and has characters after it) as
specifying a user, and also inspect "-o" style options (both "-oUser=..." and
"-o" followed by "User=...") to detect a User= assignment, then use that
combined check to decide whether to skip adding the default username so the
default is only injected when no user is present.

@mangelajo
Copy link
Member Author

Thank you @bennyz :-D

@mangelajo mangelajo merged commit 7483091 into jumpstarter-dev:main Sep 29, 2025
18 checks passed
@jumpstarter-backport-bot
Copy link

Successfully created backport PR for release-0.7:

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants