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
36 changes: 25 additions & 11 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ version when available. `google.rpc.RetryInfo` expresses a minimum retry delay;
it does not establish that a mutation is safe to repeat. SDKs retain the original
transport status, metadata, and unknown details alongside decoded fields.

Workspace lifecycle, membership, and sandbox-template mutations explicitly opt
into durable request admission when the client supplies a UUID. Typed adapters
Ordinary user-callable unary mutations explicitly opt into durable request
admission when the client supplies a UUID. Typed adapters
check current authorization before looking up a caller/method/workspace-scoped
key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps.
An atomic, quota-checked insert chooses one executor; owned execution survives
Expand All @@ -71,10 +71,24 @@ interruption leave permanent unresolved claims, never stealable leases.
Admission rows live outside user workspace namespaces and are bounded per caller.
Successes expire after 24 hours; cleanup uses the unique admission incarnation
and version so an old cleaner cannot delete a new attempt. Replay stores only
resource UUID/version references or deletion outcomes. It checks the original
workspace identity and current authorization, and never substitutes a same-name
resource. Intercepted mutations and credential capabilities require separate
adapters; this mechanism does not replay streams or repeat interceptor observers.
resource references and reviewed public scalar/diagnostic receipts, never
credential-bearing response snapshots. It checks original identities and current
authorization and never substitutes a same-name resource. Sandbox responses are
live projections of the original UUID; normal status reconciliation does not
invalidate replay. Refresh status additionally requires the original grant epoch.
Other resource projections retain exact-version guards. Terminal delete receipts
do not require the deleted target or parent to remain present.

Sandbox, service, provider/profile, and policy/config adapters use keyed payload
fingerprints derived from existing gateway JWT or primary TLS private material.
Replicas must share that material; missing keys or key changes fail closed without
changing admission identity. Workspace/template adapters retain their original
format. Intercepted requests carry the original decoded payload only in a private
in-memory extension. Replay reauthorizes original and current effective scopes,
requires the same effective payload, and reruns current interceptor validation.
Interceptors cannot mutate the request UUID. Server-marked replay suppresses
post-commit observation, which remains best-effort rather than an outbox.
Credential capabilities and streaming execution require separate contracts.

The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
The default local single-user deployment mode is mTLS user authentication:
Expand Down Expand Up @@ -366,7 +380,7 @@ Compute-driver, credential-driver, gateway-interceptor, and
supervisor-middleware services are compiled contracts for internal extension
boundaries, not public gateway RPCs. The current public inventory has 74
methods, 278 messages, and 13 enums
(`8fab4ae6475cc3b768db710c1fc4c0f2ed75000682749e303388ba908dda5b59`).
(`5de04dd390599ebd165111df4f14c6dd05ae77aa748866a0e3ec1a22abd63133`).
The removed `NetworkBinary.harness` field remains reserved by number and name,
so protobuf implementations cannot reuse its wire slot or source identifier.
The durable-policy compatibility decoder reads the former boolean before Prost
Expand Down Expand Up @@ -406,8 +420,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests
failures remain errors. Already-revoked sessions complete without another write
after current authorization. The removed response booleans are reserved by name
and number; this coordinated pre-1.0 API change does not alter durable schemas.
The outcome alone does not provide request deduplication. The six opted-in
workspace/template methods require a request UUID for the admission contract.
The outcome alone does not provide request deduplication. Opted-in unary methods
require a request UUID for the admission contract.

| Dual-purpose encoded root | Current decision |
|---|---|
Expand Down Expand Up @@ -460,8 +474,8 @@ advisor drafts without creating resource-specific tables.

Mutation admission uses a private, version-tagged JSON envelope in the same
object store. Its identity namespace stays stable across format changes, and an
unknown format fails closed. It contains no public response payloads and is not
part of the protobuf storage closure.
unknown format fails closed. It contains explicit typed receipts, not arbitrary
public response payloads, and is not part of the protobuf storage closure.

Each sandbox policy revision stores the complete provenance annotation map
supplied with that update. The revision payload is the authoritative immutable
Expand Down
16 changes: 16 additions & 0 deletions crates/openshell-cli/src/commands/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub async fn sandbox_provider_attach(

let response = match client
.attach_sandbox_provider(AttachSandboxProviderRequest {
request_id: String::new(),
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
expected_resource_version: resource_version,
Expand Down Expand Up @@ -159,6 +160,7 @@ pub async fn sandbox_provider_detach(

let response = match client
.detach_sandbox_provider(DetachSandboxProviderRequest {
request_id: String::new(),
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
expected_resource_version: resource_version,
Expand Down Expand Up @@ -471,6 +473,7 @@ async fn auto_create_provider(
if let Some(exact_name) = preferred_name {
// Explicit name: create with exactly that name, no retries.
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -519,6 +522,7 @@ async fn auto_create_provider(
};

let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -670,6 +674,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure(
) -> Result<()> {
match client
.delete_provider(DeleteProviderRequest {
request_id: String::new(),
allow_missing: true,
name: provider_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1101,6 +1106,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

let response = client
.create_provider(CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -1140,6 +1146,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

if let Err(configure_err) = client
.configure_provider_refresh(ConfigureProviderRefreshRequest {
request_id: String::new(),
provider: provider_name.clone(),
credential_key: adc_credential_key.clone(),
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
Expand All @@ -1165,6 +1172,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

if let Err(rotate_err) = client
.rotate_provider_credential(RotateProviderCredentialRequest {
request_id: String::new(),
provider: provider_name.clone(),
credential_key: adc_credential_key,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1581,6 +1589,7 @@ pub async fn provider_profile_import(
if !items.is_empty() {
let response = client
.import_provider_profiles(ImportProviderProfilesRequest {
request_id: String::new(),
profiles: items,
workspace: workspace.to_string(),
})
Expand Down Expand Up @@ -1630,6 +1639,7 @@ pub async fn provider_profile_update(
.map_or(0, |profile| profile.resource_version);
let response = client
.update_provider_profiles(UpdateProviderProfilesRequest {
request_id: String::new(),
profile: Some(item),
expected_resource_version,
id: id.to_string(),
Expand Down Expand Up @@ -1694,6 +1704,7 @@ pub async fn provider_profile_delete(
for id in ids {
let response = match client
.delete_provider_profile(DeleteProviderProfileRequest {
request_id: String::new(),
allow_missing: true,
id: id.clone(),
workspace: workspace.to_string(),
Expand Down Expand Up @@ -1806,6 +1817,7 @@ pub async fn provider_refresh_config(
let mut client = grpc_client(server, tls).await?;
let status = client
.configure_provider_refresh(ConfigureProviderRefreshRequest {
request_id: String::new(),
provider: input.name.to_string(),
credential_key: input.credential_key.to_string(),
strategy: strategy as i32,
Expand Down Expand Up @@ -1839,6 +1851,7 @@ pub async fn provider_rotate(
let mut client = grpc_client(server, tls).await?;
let status = client
.rotate_provider_credential(RotateProviderCredentialRequest {
request_id: String::new(),
provider: name.to_string(),
credential_key: credential_key.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1876,6 +1889,7 @@ pub async fn provider_refresh_delete(
let mut client = grpc_client(server, tls).await?;
let response = client
.delete_provider_refresh(DeleteProviderRefreshRequest {
request_id: String::new(),
allow_missing: true,
provider: name.to_string(),
credential_key: credential_key.to_string(),
Expand Down Expand Up @@ -2313,6 +2327,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> {

let response = client
.update_provider(UpdateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -2367,6 +2382,7 @@ pub async fn provider_delete(
for name in names {
let response = match client
.delete_provider(DeleteProviderRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down
10 changes: 10 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ pub async fn sandbox_create(
)])
};
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
resource_requirements,
environment: if template.is_none() {
Expand Down Expand Up @@ -3105,6 +3106,7 @@ pub async fn sandbox_delete(

let response = match client
.delete_sandbox(DeleteSandboxRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -3165,6 +3167,7 @@ pub async fn sandbox_stop(
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.stop_sandbox(StopSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand All @@ -3188,6 +3191,7 @@ pub async fn sandbox_start(
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.start_sandbox(StartSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand Down Expand Up @@ -3286,6 +3290,7 @@ pub async fn service_expose(
let mut client = grpc_client(server, tls).await?;
let response = client
.expose_service(ExposeServiceRequest {
request_id: String::new(),
sandbox: sandbox.to_string(),
service: service.to_string(),
target_port: u32::from(target_port),
Expand Down Expand Up @@ -3412,6 +3417,7 @@ pub async fn service_delete(
let mut client = grpc_client(server, tls).await?;
let response = client
.delete_service(DeleteServiceRequest {
request_id: String::new(),
allow_missing: false,
sandbox: sandbox.to_string(),
service: service.to_string(),
Expand Down Expand Up @@ -5668,6 +5674,7 @@ pub async fn sandbox_draft_approve(

let response = client
.approve_draft_chunk(ApproveDraftChunkRequest {
request_id: String::new(),
name: name.to_string(),
chunk_id: chunk_id.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -5700,6 +5707,7 @@ pub async fn sandbox_draft_reject(

client
.reject_draft_chunk(RejectDraftChunkRequest {
request_id: String::new(),
name: name.to_string(),
chunk_id: chunk_id.to_string(),
reason: reason.to_string(),
Expand Down Expand Up @@ -5741,6 +5749,7 @@ pub async fn sandbox_draft_approve_all(

let response = client
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
request_id: String::new(),
name: name.to_string(),
include_security_flagged,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -5772,6 +5781,7 @@ pub async fn sandbox_draft_clear(

let response = client
.clear_draft_chunks(ClearDraftChunksRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-gateway-interceptors/src/proto_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ mod tests {
let codec =
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
providers: vec!["github".to_string()],
..SandboxSpec::default()
Expand Down Expand Up @@ -336,6 +337,7 @@ mod tests {
fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() {
let codec = ProtoJsonCodec::openshell().unwrap();
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
r#type: "github".to_string(),
credentials: HashMap::from([(
Expand Down Expand Up @@ -396,6 +398,7 @@ mod tests {
fn generic_sandbox_environment_remains_visible() {
let codec = ProtoJsonCodec::openshell().unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]),
..SandboxSpec::default()
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-gateway-interceptors/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ mod tests {

fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation {
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
r#type: "github".to_string(),
credentials: HashMap::from([(
Expand Down Expand Up @@ -1009,6 +1010,7 @@ mod tests {
codec: codec.clone(),
};
let request = UpdateConfigRequest {
request_id: String::new(),
name: "demo".to_string(),
expected_resource_version: u64::MAX - 1,
annotations: HashMap::from([
Expand Down Expand Up @@ -1049,6 +1051,7 @@ mod tests {
let codec =
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
resources: Some(
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-sdk/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ impl OpenShellClient {
let response = self
.unary(|mut grpc| {
let request = proto::DeleteSandboxRequest {
request_id: String::new(),
allow_missing: opts.allow_missing,
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector("default")),
Expand All @@ -374,6 +375,7 @@ impl OpenShellClient {
let response = self
.unary(|mut grpc| {
let request = proto::StopSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector("default")),
};
Expand All @@ -388,6 +390,7 @@ impl OpenShellClient {
let response = self
.unary(|mut grpc| {
let request = proto::StartSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector("default")),
};
Expand Down Expand Up @@ -938,6 +941,7 @@ impl WorkspaceScopedClient {
.client
.unary(|mut grpc| {
let request = proto::DeleteSandboxRequest {
request_id: String::new(),
allow_missing: opts.allow_missing,
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
Expand All @@ -957,6 +961,7 @@ impl WorkspaceScopedClient {
.client
.unary(|mut grpc| {
let request = proto::StopSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
};
Expand All @@ -972,6 +977,7 @@ impl WorkspaceScopedClient {
.client
.unary(|mut grpc| {
let request = proto::StartSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
};
Expand Down Expand Up @@ -1155,6 +1161,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
gpu: Some(proto::GpuResourceRequirements { count: None }),
});
proto::CreateSandboxRequest {
request_id: String::new(),
spec: Some(proto::SandboxSpec {
environment,
template,
Expand Down Expand Up @@ -1186,6 +1193,7 @@ fn create_sandbox_from_template_request(
policy,
} = spec;
proto::CreateSandboxRequest {
request_id: String::new(),
spec: Some(proto::SandboxSpec {
providers,
command,
Expand Down
Loading
Loading