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
1 change: 1 addition & 0 deletions .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ jobs:
integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }}
test-matrix: >-
[
{"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"driver-podman"},
{"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"}
]

Expand Down
15 changes: 15 additions & 0 deletions CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by
`Required CI Gates` plus the direct `OpenShell / Trivy Changes` result, not the
push-triggered workflow jobs themselves.

### Run only the policy advisor conformance tests

Manually dispatch `Integration Tests` on the candidate branch with an
`artifact-run-id` from a build of the same commit. Set `category` to
`policy-advisor` and `test-matrix` to:

```json
[{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"policy-advisor"}]
```

This runs the `mechanistic-proposal` and `policy-local` conformance tests in the
installed-artifact suite. The artifact run must contain the candidate CLI and
gateway binaries and runtime images. This manual run does not replace the
required PR E2E gate.

## Informational security reports

Security workflow compute runs directly on GitHub-hosted runners instead of
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,11 @@ lifecycle management, output parsing, and cleanup.
Suites:

- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
- CLI conformance (`openshell-conformance`) - the portable deployment smoke
scenario plus focused tests for its reusable command runner.
- CLI conformance (`openshell-conformance`) - named scenarios for lifecycle,
mechanistic drafts, and the sandbox-local API, including agent-authored
permission requests. Driver E2E wrappers run every scenario. The
installed-artifact conformance suite runs all scenarios and offers a focused
`policy-advisor` testsuite for manual integration runs.
- Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or
`e2e-vm`) - CLI conformance plus the common and driver-specific coverage for
the selected deployment.
Expand Down
15 changes: 13 additions & 2 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ IDs fail instead of creating source precedence. The gateway treats configured
interceptors as trusted sources and does not verify signature annotations in
their profile payloads.

The CLI exposes reusable profile definitions through `openshell profile`, with `list` and `describe` reading the same effective catalog used by provider creation. Export, import, update, lint, and delete share that top-level command group. Workspace selection and explicit platform scope apply at the existing profile API boundary; `openshell provider` manages credential-bearing instances.
The CLI exposes reusable profile definitions through `openshell profile`, with `list` and `describe` reading the same effective catalog used by provider creation. Export, import, update, lint, and delete share that top-level command group. Import and lint accept local files, local directories, or a single HTTP or HTTPS URL; the CLI fetches and parses remote content before submitting it to the gateway. Workspace selection and explicit platform scope apply at the existing profile API boundary; `openshell provider` manages credential-bearing instances.

Each logical gateway request captures the selected sources into one validated,
immutable effective catalog before deriving provider behavior. Policy layers,
Expand Down Expand Up @@ -361,7 +361,8 @@ successor removes that retry path across every gateway replica. Short
that has not yet been refreshed. Omitting `gateway_jwt.ttl_secs` selects
non-expiring launch-scoped gateway and Sandbox Protocol tokens for local
single-player Docker, Podman, and VM gateways; both token profiles carry
`exp = 0`. Typed extension JWTs retain a 900-second default when the field is
`exp = 0`, and supervisors skip periodic renewal of those session tokens.
Typed extension JWTs retain a 900-second default when the field is
omitted. Kubernetes and other shared deployments should set a positive TTL.
Explicit zero is rejected.

Expand Down Expand Up @@ -1097,6 +1098,11 @@ stdout is redirected to a file), so the exec is never ended on output-idle
alone — instead an unanswered keepalive on a wedged or orphaned relay closes the
channel and returns the exec with an error. Once a command reports its exit
status, the gateway also bounds how long it waits for the trailing channel close.
An exec relay reports success only after receiving both the SSH exit status and
channel close; a missing close is a transport error even when the exit status is zero.
The supervisor derives that SSH exit status from the boundary output stream's
terminal frame after draining stdout and stderr, so an output delivery failure
cannot be masked by a successful process wait.

Interactive exec treats normal request-stream EOF as the end of stdin and resize
input. The gateway sends SSH EOF while keeping the output channel open until
Expand All @@ -1105,6 +1111,11 @@ as normal EOF. The input and output pumps are owned by the exec operation, so
timeout or response abandonment cannot leave a detached stdin task behind.
The pumps share polling fairly, and request processing yields cooperatively even
for ignored resize messages, so sustained input cannot monopolize the operation.
The CLI keeps piped input in the unary request when the complete encoded
request fits the gateway's decoder limit, preserving compatibility with older
gateways. Input up to the CLI's 4 MiB cap uses this stream with bounded frames
when the unary message would exceed the decoder limit, with or without a PTY.
The CLI closes the input side at pipe EOF.

Go and TypeScript interactive-exec helpers distinguish process exit from stream
completion. They consume the final gRPC status before reporting success and retain
Expand Down
20 changes: 20 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ While an exec handle is retained, independent waits return its stable exit or
signal status, whether or not an output attachment is open or the main process
has exited. Waiting never holds the exec registry lock, so other operations can
still signal or attach to the process.
Exec output uses a bounded queue that backpressures the process reader until its
attachment consumes the bytes. An attached reader can apply backpressure
without losing bytes; an absent reader that leaves the queue full eventually
fails the exec and terminates its process. The final stream status carries
delivery failures separately from the process handle's stable wait result.
After the exec parent exits, output drains until its pipes close. If a
descendant keeps a pipe open beyond 30 seconds, the exec reports an output
delivery failure and drains later writes without retaining them. This avoids
guessing which bytes belong to the parent.
Canonical main-process output retains its bounded replay log.

## Isolation Layers

Expand Down Expand Up @@ -321,6 +331,16 @@ captured before the bypass fence, mapped back to its workload process, authorize
through the same egress pipeline, and dialed only through the pinned addresses.
Omitted protocol endpoints retain explicit-proxy behavior.

Policy DNS reserves `policy.local` inside each sandbox without requiring an
authored network endpoint or a trusted external lookup. The source-backed TCP
boundary routes plain HTTP on the reserved address and port 80 to the
supervisor's sandbox-local policy API; it does not open an upstream connection.
The API checks the effective agent proposal setting for every request. Policy
denials at the staged TCP authorization gate enter the supervisor's denial
aggregator with the resolved binary and destination, so the mechanistic mapper
can propose a scoped rule. Invalid mappings and destination validation failures
do not become policy proposals.

Provider credential placeholders are resolved through the live provider state
for each HTTP request, after destination and L7 policy admission. A static
credential resolves only when the request host, port, and path match an endpoint
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ openshell-tui = { path = "../openshell-tui" }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yml = { workspace = true }
prost = { workspace = true }
prost-types = { workspace = true }

# Async runtime
Expand Down
118 changes: 109 additions & 9 deletions crates/openshell-cli/src/commands/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1702,10 +1702,11 @@ pub async fn provider_profile_import(
server: &str,
file: Option<&Path>,
from: Option<&Path>,
url: Option<&str>,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let (items, mut diagnostics) = load_profile_import_items(file, from)?;
let (items, mut diagnostics) = load_profile_import_items_with_url(file, from, url).await?;
if items.is_empty() && diagnostics.is_empty() {
return Err(miette!("no provider profile files found"));
}
Expand Down Expand Up @@ -1792,10 +1793,11 @@ pub async fn provider_profile_lint(
server: &str,
file: Option<&Path>,
from: Option<&Path>,
url: Option<&str>,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let (items, mut diagnostics) = load_profile_import_items(file, from)?;
let (items, mut diagnostics) = load_profile_import_items_with_url(file, from, url).await?;
if items.is_empty() && diagnostics.is_empty() {
return Err(miette!("no provider profile files found"));
}
Expand Down Expand Up @@ -2137,6 +2139,92 @@ fn load_profile_import_items(
Ok((items, diagnostics))
}

const MAX_REMOTE_PROFILE_BYTES: usize = 1024 * 1024;

async fn load_profile_import_items_with_url(
file: Option<&Path>,
from: Option<&Path>,
url: Option<&str>,
) -> Result<(
Vec<ProviderProfileImportItem>,
Vec<ProviderProfileDiagnostic>,
)> {
let Some(source) = url else {
return load_profile_import_items(file, from);
};
let parsed = reqwest::Url::parse(source)
.into_diagnostic()
.wrap_err("invalid provider profile URL")?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(miette!("provider profile URL must use http or https"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(miette!("provider profile URL must not contain userinfo"));
}
let mut display_url = parsed.clone();
display_url.set_query(None);
display_url.set_fragment(None);
let path = parsed.path().to_owned();
let format = Path::new(&path).extension().and_then(|ext| ext.to_str());
if !matches!(format, Some("yaml" | "yml" | "json")) {
return Err(miette!(
"provider profile URL must end in .yaml, .yml, or .json"
));
}
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.into_diagnostic()?;
let mut response = client
.get(parsed)
.send()
.await
.map_err(|err| {
miette!(
"failed to fetch provider profile from {display_url}: {}",
err.without_url()
)
})?
.error_for_status()
.map_err(|err| {
miette!(
"failed to fetch provider profile from {display_url}: {}",
err.without_url()
)
})?;
if response
.content_length()
.is_some_and(|len| len > MAX_REMOTE_PROFILE_BYTES as u64)
{
return Err(miette!(
"provider profile at {display_url} exceeds the 1 MiB download limit"
));
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|err| {
miette!(
"failed to read provider profile from {display_url}: {}",
err.without_url()
)
})? {
if bytes.len().saturating_add(chunk.len()) > MAX_REMOTE_PROFILE_BYTES {
return Err(miette!(
"provider profile at {display_url} exceeds the 1 MiB download limit"
));
}
bytes.extend_from_slice(&chunk);
}
let input = std::str::from_utf8(&bytes)
.into_diagnostic()
.wrap_err_with(|| format!("provider profile at {display_url} is not UTF-8"))?;
let item = parse_profile_import_item(display_url.as_str(), input, format);
Ok(match item {
Ok(item) => (vec![item], Vec::new()),
Err(diagnostic) => (Vec::new(), vec![diagnostic]),
})
}

fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result<Vec<PathBuf>> {
if let Some(file) = file {
return Ok(vec![file.to_path_buf()]);
Expand Down Expand Up @@ -2176,19 +2264,31 @@ fn load_profile_import_item(
format!("failed to read provider profile file: {err}"),
)
})?;
let profile = match path.extension().and_then(|ext| ext.to_str()) {
Some("yaml" | "yml") => parse_profile_yaml(&input),
Some("json") => parse_profile_json(&input),
parse_profile_import_item(
&source,
&input,
path.extension().and_then(|ext| ext.to_str()),
)
}

fn parse_profile_import_item(
source: &str,
input: &str,
format: Option<&str>,
) -> Result<ProviderProfileImportItem, ProviderProfileDiagnostic> {
let profile = match format {
Some("yaml" | "yml") => parse_profile_yaml(input),
Some("json") => parse_profile_json(input),
_ => {
return Err(profile_file_diagnostic(
&source,
source,
"unsupported provider profile file format".to_string(),
));
}
}
.map_err(|err| profile_file_diagnostic(&source, err.to_string()))?;
.map_err(|err| profile_file_diagnostic(source, err.to_string()))?;

let pre_lower = profile.validate_before_lowering(&source);
let pre_lower = profile.validate_before_lowering(source);
if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") {
return Err(ProviderProfileDiagnostic {
source: diag.source,
Expand All @@ -2201,7 +2301,7 @@ fn load_profile_import_item(

Ok(ProviderProfileImportItem {
profile: Some(profile.to_proto()),
source,
source: source.to_string(),
})
}

Expand Down
Loading
Loading