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
7 changes: 7 additions & 0 deletions .github/workflows/release-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ jobs:
name: Ubuntu Snap with system Docker
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
env:
OPENSHELL_INSTALL_METHOD: snap
timeout-minutes: 20
steps:
- name: Install snapd
Expand Down Expand Up @@ -232,6 +234,9 @@ jobs:
sudo snap connections openshell | grep -E '^docker +openshell:docker +:docker +'
openshell --version
sudo snap services openshell
sudo journalctl -b -u snap.openshell.gateway.service --no-pager |
grep -F "mTLS user authentication enabled"
openshell gateway list | grep -F "https://127.0.0.1:17670"
openshell status

- name: Create and exercise a sandbox
Expand Down Expand Up @@ -262,6 +267,8 @@ jobs:
name: Ubuntu Snap Docker preflight
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
env:
OPENSHELL_INSTALL_METHOD: snap
timeout-minutes: 20
steps:
- name: Install snapd
Expand Down
360 changes: 38 additions & 322 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion architecture/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenShell Architecture

OpenShell runs autonomous AI agents in sandboxed environments with explicit
OpenShell runs fleets of autonomous AI agents in sandboxed environments with explicit
policy, credential, identity, and network boundaries. The target architecture is
built around three stable runtime components: the **CLI**, the **Gateway**, and
the **Supervisor**.
Expand Down Expand Up @@ -29,6 +29,7 @@ flowchart TB

subgraph CP["Control Plane"]
GW["Gateway core"]
PROVER["Policy prover"]
DB[("Shared persistence")]
COMPUTE["Compute"]
CREDS["Credentials"]
Expand Down Expand Up @@ -59,6 +60,7 @@ flowchart TB
TUI -->|"gRPC / HTTP"| GW

GW --> DB
GW -->|"proposed policy changes"| PROVER
GW --> COMPUTE
GW --> CREDS
GW --> CPIDENT
Expand Down Expand Up @@ -89,6 +91,7 @@ flowchart TB
|---|---|
| CLI, SDK, TUI | User-facing management surfaces. They talk to the gateway and do not need to know which infrastructure drivers are active. |
| Gateway | Authenticated control plane, API server, durable state, policy and settings delivery, provider config, supervisor session ownership, and relay coordination. |
| Policy prover | Gateway-linked formal verification of proposed policy changes. It reports categorical findings (new credentialed reach, new HTTP methods, L7 bypass, link-local reach); any finding blocks auto-approval. The same crate backs the standalone `openshell-prover check` boundary command, which runs on local files without a gateway. See [Security Policy](security-policy.md). |
| Compute subsystem | Sandbox lifecycle semantics: creation, deletion, watching, reconciliation, and state transitions. Platform provisioning details belong to the compute driver. |
| Credentials subsystem | Logical provider and credential resolution. Secret storage and platform-native credential access belong to credentials drivers. |
| Control-plane identity | Authentication and authorization for users, operators, and API clients. External identity verification belongs to identity drivers. |
Expand Down
32 changes: 19 additions & 13 deletions architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,24 +324,30 @@ for direct executable installation on every environment. Release Dev and
Release Tag run Ubuntu conformance through the Debian package, while Fedora
continues using direct executable installation until RPM coverage is available.
The release canary separately exercises the public installer on Ubuntu. The
OpenShell Snap requires a compatible, preinstalled non-Snap Docker daemon. Its
positive canary uses system Docker; negative preflight coverage verifies that
the installer rejects both missing Docker and the Docker Snap before installing
OpenShell. Its Debian lane removes snapd before running the installer so Snap
precedence cannot change the package under test.
Explicit release tags and the `pre` alias bypass Snap selection and use the
native Debian or RPM package path even when `snap` is available. The `pre` alias
installer selects the OpenShell Snap only with `OPENSHELL_INSTALL_METHOD=snap`
or when the Snap is already installed; otherwise it uses the native Debian or
RPM package. The Snap requires a compatible, preinstalled non-Snap Docker
daemon. Its positive canary uses system Docker; negative preflight coverage
verifies that the installer rejects both missing Docker and the Docker Snap
before installing OpenShell.
Explicit release tags and the `pre` alias always use the native Debian or RPM
package path. The `pre` alias
checks matching Git tags in version order, then looks up the exact platform
artifact and verifies the release run instead of listing every repository
artifact.

Snapd runs the gateway as a root-owned system service. Its generated client
certificates reside in root-owned snap state and are unavailable to ordinary CLI
users, so the Snap uses plaintext loopback transport and enables unauthenticated
local users by default. Debian and RPM packages instead run systemd user services
and use user-owned mTLS material. Bootstrap creates the default configuration
only when it is missing. Sandbox-to-gateway sessions remain authenticated with
gateway-minted JWTs.
certificates reside in root-owned snap state. The installer copies the client
bundle into the target user's private Snap state and registers the TLS endpoint;
direct Snap installs require the same enrollment. The install and post-refresh
hooks replace configs that explicitly enable plaintext or unauthenticated access
with the secure default. Snap refreshes
restart the gateway so the migrated config takes effect immediately.

Debian and RPM packages instead run systemd user services with user-owned mTLS
material. Sandbox-to-gateway sessions remain authenticated with gateway-minted
JWTs.

The Debian qualification profile keeps candidate-image overrides outside the
operator-owned gateway configuration: it writes a harness-owned file under
`/var/lib/openshell-qualification` and selects it through the packaged systemd
Expand Down
3 changes: 2 additions & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ attachments; and asks compute runtimes to create or delete sandbox workloads.
- Authenticate clients and sandbox supervisor sessions.
- Serve gRPC APIs for sandbox lifecycle, provider management, policy updates,
settings, logs, watch streams, and relay forwarding.
- Serve HTTP endpoints for health, WebSocket tunnels, and edge-auth flows.
- Serve HTTP endpoints for health and edge-auth flows, plus the opt-in
WebSocket tunnel for edge-proxy deployments.
- Persist domain objects in SQLite or Postgres.
- Resolve endpoint-bound provider environments for sandbox supervisors.
- Coordinate supervisor relay sessions for connect, exec, file sync, and
Expand Down
39 changes: 32 additions & 7 deletions crates/openshell-binary-identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@ fn cmdline_absolute_paths(cmdline: &[u8]) -> Vec<std::path::PathBuf> {
mod tests {
use super::*;

const IDENTITY_HELPER_READY: &str = "identity helper ready\n";

struct ChildGuard(std::process::Child);

impl Drop for ChildGuard {
Expand All @@ -474,20 +476,22 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
fn resolver_cache_is_owned_and_only_explicit_clones_share_it() {
let first = ProcfsIdentityResolver::for_pid_namespace();
let pid = std::process::id();
let first = ProcfsIdentityResolver::for_process_tree(pid);
let shared = first.clone();
let separate = ProcfsIdentityResolver::for_pid_namespace();
let separate = ProcfsIdentityResolver::for_process_tree(pid);
assert!(Arc::ptr_eq(&first.cache, &shared.cache));
assert!(!Arc::ptr_eq(&first.cache, &separate.cache));
first.resolve(std::process::id()).unwrap();
first.resolve(pid).unwrap();
assert!(!shared.cache.lock().unwrap().is_empty());
assert!(separate.cache.lock().unwrap().is_empty());
}

#[test]
fn resolves_current_process_from_live_executable() {
let identity = ProcfsIdentityResolver::for_pid_namespace()
.resolve(std::process::id())
let pid = std::process::id();
let identity = ProcfsIdentityResolver::for_process_tree(pid)
.resolve(pid)
.expect("resolve current process");

assert!(identity.executable.path.is_absolute());
Expand All @@ -497,16 +501,37 @@ mod tests {
#[test]
#[ignore = "subprocess fixture for executable identity tests"]
fn identity_helper_process() {
std::thread::sleep(std::time::Duration::from_secs(30));
let mut stdout = std::io::stdout();
std::io::Write::write_all(&mut stdout, IDENTITY_HELPER_READY.as_bytes())
.expect("signal helper readiness");
std::io::Write::flush(&mut stdout).expect("flush helper readiness");

// Stay alive until the parent closes stdin or ChildGuard terminates us.
let _ = std::io::Read::read(&mut std::io::stdin(), &mut [0_u8]);
}

#[test]
fn resolves_child_and_hashes_ancestor_chain() {
let parent_pid = std::process::id();
let child = std::process::Command::new(std::env::current_exe().unwrap())
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--ignored", "--exact", "tests::identity_helper_process"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn child");
let mut child_stdout = std::io::BufReader::new(child.stdout.take().expect("child stdout"));
let mut line = String::new();
// Wait until the helper has entered the test before taking the
// fail-closed procfs snapshot of the newly spawned process.
loop {
line.clear();
let bytes_read = std::io::BufRead::read_line(&mut child_stdout, &mut line)
.expect("read child readiness");
assert_ne!(bytes_read, 0, "child exited before signaling readiness");
if line == IDENTITY_HELPER_READY {
break;
}
}
let child = ChildGuard(child);

let identity = ProcfsIdentityResolver::for_process_tree(parent_pid)
Expand Down
12 changes: 12 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ pub struct Config {
/// Gateway user authentication behavior.
pub auth: GatewayAuthConfig,

/// Allow the WebSocket tunnel used by authenticated edge proxies.
/// Disabled for local gateways by default.
pub enable_websocket_tunnel: bool,

/// Disabled-by-default gateway interceptor service configs.
pub gateway_interceptors: Vec<GatewayInterceptorConfig>,

Expand Down Expand Up @@ -850,6 +854,7 @@ impl Config {
tls,
oidc: None,
auth: GatewayAuthConfig::default(),
enable_websocket_tunnel: false,
gateway_interceptors: Vec::new(),
provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User],
mtls_auth: MtlsAuthConfig::default(),
Expand Down Expand Up @@ -1022,6 +1027,13 @@ impl Config {
self.service_routing.enable_loopback_service_http = enabled;
self
}

/// Enable the WebSocket tunnel for an authenticated edge proxy.
#[must_use]
pub const fn with_websocket_tunnel(mut self, enabled: bool) -> Self {
self.enable_websocket_tunnel = enabled;
self
}
}

impl Default for ServiceRoutingConfig {
Expand Down
41 changes: 40 additions & 1 deletion crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ struct RunArgs {
action = ArgAction::Set
)]
enable_loopback_service_http: bool,

/// Enable the WebSocket tunnel for an authenticated edge proxy.
#[arg(
long,
env = "OPENSHELL_ENABLE_WEBSOCKET_TUNNEL",
default_value_t = false,
action = ArgAction::Set
)]
enable_websocket_tunnel: bool,
}

pub fn command() -> Command {
Expand Down Expand Up @@ -508,7 +517,8 @@ fn prepare_server_config_with_drivers(
.unwrap_or_default(),
)
.with_server_sans(args.server_sans.clone())
.with_loopback_service_http(args.enable_loopback_service_http);
.with_loopback_service_http(args.enable_loopback_service_http)
.with_websocket_tunnel(args.enable_websocket_tunnel);
if let Some(sources) = file
.as_ref()
.and_then(|file| file.openshell.gateway.provider_profile_sources.clone())
Expand Down Expand Up @@ -1089,6 +1099,11 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches:
{
args.enable_loopback_service_http = enabled;
}
if let Some(enabled) = file.enable_websocket_tunnel
&& arg_defaulted(matches, "enable_websocket_tunnel")
{
args.enable_websocket_tunnel = enabled;
}
if let Some(mtls_auth) = &file.mtls_auth
&& arg_defaulted(matches, "enable_mtls_auth")
{
Expand Down Expand Up @@ -1440,6 +1455,30 @@ mod tests {
assert!(cli.run.enable_loopback_service_http);
}

#[test]
fn websocket_tunnel_is_disabled_by_default_and_can_be_enabled_from_file() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL");
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
assert!(!args.enable_websocket_tunnel);

let file = config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n");
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
assert!(args.enable_websocket_tunnel);

let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--enable-websocket-tunnel=false",
]);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
assert!(!args.enable_websocket_tunnel, "CLI flag must override file");
}

#[test]
fn command_disables_loopback_service_http_with_false_value() {
let _lock = ENV_LOCK
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ pub struct GatewayFileSection {
/// Enable plaintext HTTP routing for loopback sandbox service URLs.
#[serde(default)]
pub enable_loopback_service_http: Option<bool>,
/// Enable the WebSocket tunnel for an authenticated edge proxy.
#[serde(default)]
pub enable_websocket_tunnel: Option<bool>,

// ── Sandbox client TLS ───────────────────────────────────────────────
#[serde(default)]
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-server/src/grpc/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6302,6 +6302,7 @@ mod tests {
"google-vertex-ai",
"nvidia",
"openai",
"openrouter",
"pypi"
]
);
Expand Down
14 changes: 8 additions & 6 deletions crates/openshell-server/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,14 @@ async fn render_metrics(State(handle): State<PrometheusHandle>) -> impl IntoResp

/// Create the HTTP router served on the multiplexed gateway port.
pub fn http_router(state: Arc<crate::ServerState>) -> Router {
crate::ws_tunnel::router(state.clone())
.merge(crate::auth::router(state.clone()))
.layer(middleware::from_fn_with_state(
state,
sandbox_service_routing_first,
))
let mut router = crate::auth::router(state.clone());
if state.config.enable_websocket_tunnel {
router = router.merge(crate::ws_tunnel::router(state.clone()));
}
router.layer(middleware::from_fn_with_state(
state,
sandbox_service_routing_first,
))
}

/// Create the plaintext loopback-only router for browser service endpoints.
Expand Down
24 changes: 24 additions & 0 deletions crates/openshell-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,9 @@ mod tests {
use tokio::sync::watch;

use crate::tls_test_utils::generate_test_certs_with_ca;
use axum::body::Body;
use http::{Request, StatusCode};
use tower::ServiceExt;

fn tls_enabled_config() -> Config {
Config::new(Some(openshell_core::TlsConfig {
Expand Down Expand Up @@ -2174,6 +2177,27 @@ mod tests {
))
}

#[tokio::test]
async fn websocket_tunnel_is_mounted_only_when_enabled() {
let state = test_state("127.0.0.1:17670".parse().unwrap(), true).await;
let response = super::http_router(state.clone())
.oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);

let mut enabled = state;
Arc::get_mut(&mut enabled)
.unwrap()
.config
.enable_websocket_tunnel = true;
let response = super::http_router(enabled)
.oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(response.status(), StatusCode::NOT_FOUND);
}

async fn start_tls_gateway_listener(
bind_addr: &str,
enable_loopback_service_http: bool,
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/openshell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ discovery endpoint or its TLS CA.
| server.drivers.kubernetes.workspaceMode | string | `"shared"` | How workspaces map to Kubernetes namespaces. "shared" (default): all sandboxes in a single namespace. "managed": auto-creates per-workspace namespaces. "operator": uses pre-provisioned namespaces. |
| server.enableLoopbackServiceHttp | bool | `true` | Enable plaintext HTTP routing for loopback sandbox service URLs on TLS-enabled gateways. |
| server.enableUserNamespaces | bool | `false` | Enable Kubernetes user namespace isolation (hostUsers: false) for sandbox pods. Requires Kubernetes 1.33+ with user namespace support available (beta through 1.35, GA in 1.36+), plus a supporting container runtime and Linux 5.12+. When enabled, container UID 0 maps to an unprivileged host UID and capabilities become namespaced. |
| server.enableWebsocketTunnel | bool | `false` | Enable the WebSocket tunnel used by CLI/SDK clients behind an authenticated edge proxy. Leave disabled for direct gateway installs. |
| server.externalDbSecret | string | `""` | Name of a pre-existing Opaque Secret containing a PostgreSQL connection URI (key: uri). When set, the gateway reads OPENSHELL_DB_URL from this Secret instead of using dbUrl. The Secret must contain a `uri` key, e.g. postgresql://user:pass@host:5432/dbname. |
| server.grpcEndpoint | string | `""` | gRPC endpoint sandboxes call back into the gateway. Leave empty to derive it from the chart fullname, release namespace, service port, and disableTls flag, for example https://openshell.openshell.svc.cluster.local:8080. Override only when sandboxes must reach the gateway via a different hostname (e.g. an external ingress or a host alias). |
| server.grpcRateLimit.requests | int | `0` | Maximum gRPC requests allowed per window. Must be positive (alongside windowSeconds) to enable rate limiting; 0 (default) disables it. |
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/openshell/templates/gateway-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ data:
disable_tls = true
{{- end }}
enable_loopback_service_http = {{ .Values.server.enableLoopbackServiceHttp }}
enable_websocket_tunnel = {{ .Values.server.enableWebsocketTunnel }}
{{- $sans := list -}}
{{- if and .Values.certManager.enabled .Values.certManager.serverDnsNames }}
{{- $sans = .Values.certManager.serverDnsNames }}
Expand Down
Loading
Loading