Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,35 @@ Rust-based e2e tests that exercise the `openshell` CLI binary as a subprocess.
They live in the `openshell-e2e` crate and use a shared harness for sandbox
lifecycle management, output parsing, and cleanup.

Exposed service URLs use virtual hostnames for gateway routing. Host-side tests
must connect the TCP socket directly to a reachable gateway listener address,
normally loopback, and send the service URL authority in the HTTP `Host`
header. Do not resolve `*.openshell.localhost`; resolver support for arbitrary
`.localhost` subdomains varies across local and CI environments.

Treat the advertised service URL scheme as authoritative. For HTTPS, use the
virtual service hostname for TLS SNI and the configured gateway trust roots.
When the listener requires mTLS, present the active gateway client identity;
the local e2e wrappers register these materials under
`$XDG_CONFIG_HOME/openshell/gateways/$OPENSHELL_GATEWAY/mtls/`. Do not downgrade
an HTTPS service URL to plaintext when dialing loopback. Parse the URL and load
TLS material before entering a readiness loop so permanent configuration
errors fail immediately. Retry only transient connection failures and
documented readiness responses, and include the last observation in timeout
diagnostics.

Verify exposed-service tests in both the default local mode and the
CI-equivalent HTTPS mode:

```shell
mise run e2e:rust
OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false mise run e2e:rust
```

When more than one test needs this behavior, put the transport in the shared
Rust e2e harness and require callers to use it instead of duplicating DNS,
HTTP `Host`, TLS SNI, and mTLS handling.

Suites:

- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
Expand Down
112 changes: 110 additions & 2 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use openshell_bootstrap::{
use openshell_cli::completers;
use openshell_cli::run;
use openshell_cli::tls::TlsOptions;
use openshell_core::proto::GpuResourceRequirements;
use openshell_core::proto::{GpuResourceRequirements, ServiceAuthorizationMode};

/// Resolved gateway context: name + gateway endpoint.
struct GatewayContext {
Expand Down Expand Up @@ -770,6 +770,22 @@ enum OutputFormat {
Json,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum CliServiceAuthorizationMode {
#[default]
Strip,
BearerPassthrough,
}

impl From<CliServiceAuthorizationMode> for ServiceAuthorizationMode {
fn from(value: CliServiceAuthorizationMode) -> Self {
match value {
CliServiceAuthorizationMode::Strip => Self::Strip,
CliServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
}
}
}

#[derive(Clone, Debug, ValueEnum)]
enum CliProviderRefreshStrategy {
Oauth2RefreshToken,
Expand Down Expand Up @@ -1520,6 +1536,10 @@ enum SandboxCommands {
)]
expose: Option<u16>,

/// Handling for an incoming application Authorization header.
#[arg(long, value_enum, default_value_t, requires = "expose")]
expose_authorization_mode: CliServiceAuthorizationMode,

/// Allocate a pseudo-terminal for the remote command.
/// Defaults to auto-detection (on when stdin and stdout are terminals).
/// Use --tty to force a PTY even when auto-detection fails, or
Expand Down Expand Up @@ -2364,6 +2384,10 @@ enum ServiceCommands {

/// Service name.
service: Option<String>,

/// Handling for an incoming application Authorization header.
#[arg(long, value_enum, default_value_t)]
authorization_mode: CliServiceAuthorizationMode,
},

/// List exposed sandbox service endpoints.
Expand Down Expand Up @@ -2909,13 +2933,15 @@ async fn run_async() -> Result<()> {
sandbox,
service,
target_port,
authorization_mode,
} => {
let service = service.unwrap_or_default();
run::service_expose(
&ctx.endpoint,
&sandbox,
&service,
target_port,
authorization_mode.into(),
&cli.workspace,
&tls,
)
Expand Down Expand Up @@ -3319,6 +3345,7 @@ async fn run_async() -> Result<()> {
policy,
forward,
expose,
expose_authorization_mode,
tty,
no_tty,
detach,
Expand Down Expand Up @@ -3416,6 +3443,7 @@ async fn run_async() -> Result<()> {
policy: policy.as_deref(),
forward,
expose,
expose_authorization_mode: expose_authorization_mode.into(),
command: &command,
tty_override,
auto_providers_override,
Expand Down Expand Up @@ -6626,9 +6654,19 @@ mod tests {

match cli.command {
Some(Commands::Sandbox {
command: Some(SandboxCommands::Create { expose, detach, .. }),
command:
Some(SandboxCommands::Create {
expose,
expose_authorization_mode,
detach,
..
}),
}) => {
assert_eq!(expose, Some(4500));
assert_eq!(
expose_authorization_mode,
CliServiceAuthorizationMode::Strip
);
assert!(detach);
}
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
Expand Down Expand Up @@ -6656,6 +6694,44 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn sandbox_create_parses_bearer_passthrough_and_requires_expose() {
let cli = Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--expose",
"4500",
"--expose-authorization-mode",
"bearer-passthrough",
])
.expect("create-time authorization mode should parse with --expose");
match cli.command {
Some(Commands::Sandbox {
command:
Some(SandboxCommands::Create {
expose_authorization_mode,
..
}),
}) => assert_eq!(
expose_authorization_mode,
CliServiceAuthorizationMode::BearerPassthrough
),
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
}

assert!(
Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--expose-authorization-mode",
"bearer-passthrough",
])
.is_err()
);
}

#[test]
fn service_expose_accepts_positional_target_port_and_service() {
let cli = Cli::try_parse_from([
Expand All @@ -6675,11 +6751,13 @@ mod tests {
sandbox,
target_port,
service,
authorization_mode,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service.as_deref(), Some("api"));
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
}
other => panic!("expected service expose command, got: {other:?}"),
}
Expand All @@ -6697,16 +6775,45 @@ mod tests {
sandbox,
target_port,
service,
authorization_mode,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service, None);
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
}
other => panic!("expected service expose command, got: {other:?}"),
}
}

#[test]
fn service_expose_parses_bearer_passthrough() {
let cli = Cli::try_parse_from([
"openshell",
"service",
"expose",
"my-sandbox",
"4500",
"--authorization-mode",
"bearer-passthrough",
])
.expect("service authorization mode should parse");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
authorization_mode, ..
}),
}) => assert_eq!(
authorization_mode,
CliServiceAuthorizationMode::BearerPassthrough
),
other => panic!("expected service expose command, got: {other:?}"),
}
}

#[test]
fn service_alias_parses_service_commands() {
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
Expand All @@ -6719,6 +6826,7 @@ mod tests {
sandbox,
target_port,
service,
..
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
Expand Down
Loading
Loading