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
10 changes: 10 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ failure-output = "immediate-final"
# Flag slow tests after 60s and terminate them after five minutes.
slow-timeout = { period = "60s", terminate-after = 5 }

# The HA tests scale and roll the shared gateway Deployment, so they cannot
# overlap. Nextest runs each test in its own process. The filter matches test
# names because binary() fails in workspaces without the HA test binary.
[test-groups]
kubernetes-ha = { max-threads = 1 }

[[profile.e2e-kubernetes.overrides]]
filter = "test(/gateway_(scale_and_rollout|pod_rolls)$/)"
test-group = "kubernetes-ha"

# Relative to the profile store dir (`e2e/rust/target/nextest/e2e-kubernetes/`).
[profile.e2e-kubernetes.junit]
path = "../../../../../results/e2e-kubernetes.xml"
6 changes: 3 additions & 3 deletions .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ jobs:
push)
run_core_e2e="$(jq -r 'index("test:e2e") != null' <<< "$LABELS_JSON")"
run_gpu_e2e="$(jq -r 'index("test:e2e-gpu") != null' <<< "$LABELS_JSON")"
run_kubernetes_ha_e2e=false
run_kubernetes_credential_drivers_e2e=false
run_kubernetes_ha_e2e="$(jq -r 'index("test:e2e-kubernetes") != null' <<< "$LABELS_JSON")"
run_kubernetes_credential_drivers_e2e="$(jq -r 'index("test:e2e-kubernetes") != null' <<< "$LABELS_JSON")"
;;
merge_group)
# Merge groups have no PR labels. When GPU E2E is required as documented
Expand Down Expand Up @@ -354,7 +354,7 @@ jobs:
matrix:
include:
- agent_sandbox_api: v1beta1
agent_sandbox_version: v0.5.0
agent_sandbox_version: v1.0.3
- agent_sandbox_api: v1alpha1
agent_sandbox_version: v0.4.6
permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-kubernetes-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ on:
description: "Agent Sandbox release to install before OpenShell"
required: false
type: string
default: "v0.5.0"
default: "v1.0.3"
e2e-task:
description: "mise task to run for the Kubernetes e2e job"
required: false
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ jobs:
RELEASE_NAME: openshell
RELEASE_NAMESPACE: openshell
KIND_GATEWAY_NAME: kind
AGENT_SANDBOX_VERSION: v0.5.0
AGENT_SANDBOX_VERSION: v1.0.3
steps:
- name: Checkout Agent Sandbox helper
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ jobs:
name: ${{ needs.qualification-result.outputs.artifact-name }}

- name: Install ORAS
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.0

- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
Expand Down
4 changes: 3 additions & 1 deletion architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,9 @@ database-backed installs can render a Deployment with `workload.kind=deployment`
HA deployments must point `server.externalDbSecret` at an operator-managed
PostgreSQL database. Agent Sandbox CRDs and controller lifecycle remain
operator-owned; the chart can optionally preflight for a served supported API
but does not install the cluster-scoped dependency.
but does not install the cluster-scoped dependency. OpenShell's Kubernetes test
clusters install the upstream core manifest; Agent Sandbox extensions are not
required by the gateway.
Standalone local deployments start the gateway with a selected runtime such as
Docker, Podman, or VM. The CLI can register multiple gateways and switch between
them without changing the sandbox architecture.
Expand Down
24 changes: 20 additions & 4 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,16 @@ modes:
and `page_token` fields, and responses carry `next_page_token`. The gateway
clamps page sizes to 1,000 and returns opaque base64url continuation tokens.
Tokens bind the RPC and every request parameter except `page_size`, contain no
authorization grant, and use immutable keyset cursors rather than database
offsets. Each page repeats normal authentication and authorization. Pagination
is weakly consistent under concurrent writes and deletes; it does not provide a
historical snapshot.
authorization grant, and use immutable keyset cursors. Each page repeats normal
authentication and authorization. Pagination is resumable and keyset-based; it
does not provide a historical snapshot while the collection is mutated
concurrently.

Object-backed lists (`ListSandboxTemplates`, `ListSandboxes`, `ListServices`,
`ListProviders`, `ListWorkspaces`, and `ListWorkspaceMembers`) sort ascending
by `(created_at_ms, name, workspace, id)`. `ListProviderProfiles` sorts
ascending by `(id, scope)`, and `ListSandboxPolicies` sorts by descending policy
version. Provider attachments sort ascending by provider name.

The token wire format is a private shared protobuf used only by the gateway.
Public request and response messages repeat the standard AIP fields directly
Expand All @@ -878,6 +884,16 @@ Curated Rust, Python, Go, and TypeScript SDK list methods return lazy pagers.
Advancing a pager issues one list RPC and exposes its continuation token;
explicit `list_all` helpers are the only curated APIs that exhaust a collection.

**Migration.** This pagination contract is a breaking replacement for the
former `limit`/`offset` list APIs. Protocol clients must send `page_size` and
resume only with the returned `next_page_token`; an empty token is the sole
completion signal. SDK callers that need every item must use the explicit
full-iteration helper (for example, Rust's `list_all_sandboxes`) rather than
awaiting `list_sandboxes`, which now returns one-page `Pager` state. Callers
that need one page should advance that pager once and retain its token. Internal
full scans use the reusable iteration helpers from the persistence pagination
audit rather than manually advancing offsets.

Persistence distinguishes one-page operations from exhaustive scans.
`list_object_page` and `list_message_page` return one keyset page and its next
cursor. `collect_records` and `collect_messages` exhaust those pages, fail on
Expand Down
10 changes: 9 additions & 1 deletion architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ the shared raw byte relay after the existing adapter gates. Forward HTTP retains
its guarded single-request relay while sharing authorization, request context,
policy-pinning, and destination boundaries.
Adapter-specific response and OCSF event shapes remain at the protocol boundary.
HTTP response framing and connection persistence are separate decisions. After
forwarding a complete closing response (explicit `Connection: close` or HTTP/1.0
without keep-alive), the relay flushes and shuts down downstream writes before
ending the exchange, including TLS close notification. Response middleware
preserves this lifetime rule; persistent responses remain eligible for reuse.

An explicit `protocol: tcp` endpoint with a valid DNS hostname opts into native
DNS and transparent TCP when the selected runtime advertises that substrate.
Hostless `allowed_ips` and literal-IP selectors remain available only to the
Expand Down Expand Up @@ -648,7 +654,9 @@ sandbox workload directly. The relay supports:

- Attachment to the canonical main process through the `openshell-main` SSH
subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay
buffer, and a single stdin lease across client disconnects.
buffer, and a single stdin lease across client disconnects. Ctrl-C interrupts
the foreground process. For read-only attachments, Ctrl-C only exits the
current viewer.
- Independent interactive shell sessions.
- Command execution. Commands run through a login shell (`bash -lc`) by default,
so the first of the user's `.bash_profile`, `.bash_login`, or `.profile` is
Expand Down
17 changes: 15 additions & 2 deletions crates/openshell-cli/src/commands/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()>
pub async fn sandbox_provider_list(
server: &str,
name: &str,
page_size: i32,
page_token: &str,
output: &str,
workspace: &str,
tls: &TlsOptions,
Expand All @@ -98,14 +100,25 @@ pub async fn sandbox_provider_list(
workspace_scope: Some(openshell_core::proto::workspace_selector(
(workspace).to_string(),
)),
page_size,
page_token: page_token.to_string(),
})
.await
.into_diagnostic()?;
let providers = response.into_inner().providers;
let response = response.into_inner();
let next_page_token = response.next_page_token;
let providers = response.providers;

if crate::output::print_output_collection(output, &providers, attached_provider_to_json)? {
if crate::output::print_paginated_output_collection(
output,
"providers",
&providers,
&next_page_token,
attached_provider_to_json,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);

if providers.is_empty() {
println!("No providers attached to sandbox {name}.");
Expand Down
17 changes: 16 additions & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,14 @@ enum SandboxProviderCommands {
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,

/// Maximum number of attached providers to return in this page.
#[arg(long, default_value_t = 100)]
page_size: i32,

/// Opaque continuation token from a previous page.
#[arg(long, default_value = "")]
page_token: String,

/// Output format.
#[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)]
output: OutputFormat,
Expand Down Expand Up @@ -3573,11 +3581,18 @@ async fn run_async() -> Result<()> {
run::print_ssh_config(&ctx.name, &name, &cli.workspace);
}
SandboxCommands::Provider(command) => match command {
SandboxProviderCommands::List { name, output } => {
SandboxProviderCommands::List {
name,
page_size,
page_token,
output,
} => {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
run::sandbox_provider_list(
endpoint,
&name,
page_size,
&page_token,
output.as_str(),
&cli.workspace,
&tls,
Expand Down
110 changes: 106 additions & 4 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ struct ProviderState {
fail_delete_provider_profile_message: Arc<Mutex<Option<String>>>,
sandbox_providers: Arc<Mutex<HashMap<String, Vec<String>>>>,
sandbox_provider_requests: Arc<Mutex<Vec<SandboxProviderRequestLog>>>,
sandbox_provider_next_page_token: Arc<Mutex<String>>,
readiness_receipts: Arc<Mutex<HashMap<String, ProviderMutationReceipt>>>,
readiness_scripts: Arc<Mutex<ReadinessScript>>,
readiness_requests: Arc<Mutex<Vec<GetSandboxProviderStatusRequest>>>,
Expand Down Expand Up @@ -139,6 +140,8 @@ enum ProviderRefreshRequestLog {
enum SandboxProviderRequestLog {
List {
sandbox_name: String,
page_size: i32,
page_token: String,
},
Attach {
sandbox_name: String,
Expand Down Expand Up @@ -376,6 +379,8 @@ impl OpenShell for TestOpenShell {
.await
.push(SandboxProviderRequestLog::List {
sandbox_name: sandbox_name.clone(),
page_size: request.page_size,
page_token: request.page_token,
});
let provider_names = self
.state
Expand All @@ -390,7 +395,15 @@ impl OpenShell for TestOpenShell {
.iter()
.filter_map(|name| providers_by_name.get(name).cloned())
.collect();
Ok(Response::new(ListSandboxProvidersResponse { providers }))
Ok(Response::new(ListSandboxProvidersResponse {
providers,
next_page_token: self
.state
.sandbox_provider_next_page_token
.lock()
.await
.clone(),
}))
}

async fn attach_sandbox_provider(
Expand Down Expand Up @@ -4160,9 +4173,17 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
)
.await
.expect("sandbox provider attach is idempotent");
run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "table", "default", &ts.tls)
.await
.expect("sandbox provider list");
run::sandbox_provider_list(
&ts.endpoint,
"dev-sandbox",
100,
"",
"table",
"default",
&ts.tls,
)
.await
.expect("sandbox provider list");
run::sandbox_provider_detach(
&ts.endpoint,
"dev-sandbox",
Expand Down Expand Up @@ -4198,6 +4219,8 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
},
SandboxProviderRequestLog::List {
sandbox_name: "dev-sandbox".to_string(),
page_size: 100,
page_token: String::new(),
},
SandboxProviderRequestLog::Detach {
sandbox_name: "dev-sandbox".to_string(),
Expand All @@ -4214,6 +4237,85 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results
assert!(providers.get("dev-sandbox").is_none_or(Vec::is_empty));
}

#[tokio::test]
async fn sandbox_provider_list_cli_forwards_pagination_and_emits_envelopes() {
let ts = run_server().await;
ts.state.providers.lock().await.insert(
"work-github".to_string(),
Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "provider-work-github".to_string(),
name: "work-github".to_string(),
workspace: "default".to_string(),
resource_version: 1,
..Default::default()
}),
r#type: "github".to_string(),
..Default::default()
},
);
ts.state
.sandbox_providers
.lock()
.await
.insert("dev-sandbox".to_string(), vec!["work-github".to_string()]);
*ts.state.sandbox_provider_next_page_token.lock().await = "next-page".to_string();

for format in ["json", "yaml"] {
let output = run_readiness_cli(
&ts,
&[
"sandbox",
"provider",
"list",
"dev-sandbox",
"--page-size",
"1",
"--page-token",
"prior-page",
"--output",
format,
],
)
.await;
assert!(
output.status.success(),
"sandbox provider list {format} failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stderr.is_empty(),
"structured output wrote to stderr"
);

let value: serde_json::Value = match format {
"json" => serde_json::from_slice(&output.stdout).expect("parse JSON envelope"),
"yaml" => serde_yml::from_slice(&output.stdout).expect("parse YAML envelope"),
_ => unreachable!(),
};
assert_eq!(value["next_page_token"], "next-page");
assert_eq!(value["providers"].as_array().map(Vec::len), Some(1));
assert_eq!(value["providers"][0]["name"], "work-github");
}

let requests = ts.state.sandbox_provider_requests.lock().await.clone();
assert_eq!(
requests,
vec![
SandboxProviderRequestLog::List {
sandbox_name: "dev-sandbox".to_string(),
page_size: 1,
page_token: "prior-page".to_string(),
},
SandboxProviderRequestLog::List {
sandbox_name: "dev-sandbox".to_string(),
page_size: 1,
page_token: "prior-page".to_string(),
},
]
);
}

#[tokio::test]
async fn sandbox_provider_attach_cli_surfaces_server_errors() {
let ts = run_server().await;
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-driver-podman/NETWORKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ does not share the workload's PID, mount, or network namespaces.
## Troubleshooting

Inspect both containers with the same sandbox-ID label, distinguishing
`openshell.io/isolation-role=sandbox` from
`openshell.io/isolation-role=supervisor`.
`openshell.ai/isolation-role=sandbox` from
`openshell.ai/isolation-role=supervisor`.

- Sandbox fails its qualification probe: use its log to identify the denied
kernel/runtime primitive. Do not add capabilities or disable runtime seccomp.
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-driver-podman/src/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use openshell_sandbox_backend::boundary_protocol::{
};
use serde::{Deserialize, Serialize};

pub const LABEL_ROLE: &str = "openshell.io/isolation-role";
pub const WORKLOAD_FILTER: &str = "openshell.io/isolation-role=sandbox";
pub const LABEL_ROLE: &str = "openshell.ai/isolation-role";
pub const WORKLOAD_FILTER: &str = "openshell.ai/isolation-role=sandbox";
pub const CHANNEL_ROOT: &str = "/.openshell/channel";
pub const BOOTSTRAP_PATH: &str = "/.openshell/channel/sandbox/bootstrap.json";
pub const RUNTIME_DESCRIPTOR_PATH: &str = "/.openshell/supervisor/runtime-descriptor.json";
Expand Down
Loading
Loading