Skip to content

Commit 17366fe

Browse files
committed
feat(api): add durable exec launch admission
Fence duplicate exec launches with keyed durable admission and producer-owned terminal completion. Keep uncertain launches unresolved and never replay output or interactive input. Part of #3051 (phase 4a). Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
1 parent 96c08f1 commit 17366fe

14 files changed

Lines changed: 1134 additions & 82 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,22 @@ in-memory extension. Replay reauthorizes original and current effective scopes,
9595
requires the same effective payload, and reruns current interceptor validation.
9696
Interceptors cannot mutate the request UUID. Server-marked replay suppresses
9797
post-commit observation, which remains best-effort rather than an outbox.
98-
Credential capabilities and streaming execution require separate contracts.
98+
Credential capabilities require separate contracts.
99+
100+
Both exec RPCs share keyed admission but defer completion to the SSH producer.
101+
The initial interactive Start uses the exec request schema under a distinct RPC
102+
namespace; later stdin and resize frames are not replayable inputs. Admission
103+
resolves the public sandbox name and workspace, durably binds both original and
104+
effective sandbox UUIDs, and rejects same-name replacements. The owner checks
105+
the effective UUID again before relay opening, then hands its CAS-only finalizer to the owned producer,
106+
releasing the shared admission-worker permit after handoff. Only a confirmed
107+
remote exit records a terminal marker and starts 24-hour retention. Synthetic
108+
timeouts, disconnects without exit confirmation, and persistence failures leave
109+
permanent unresolved claims. Duplicates never launch, attach, or replay output:
110+
pending records report uncertainty, and terminal records report stream
111+
unavailability. Existing transport cancellation behavior remains unchanged.
112+
Exec timeouts retain the public duration's precision and presence: an absent
113+
timeout is unbounded, while an explicit zero is a finite timeout.
99114

100115
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
101116
The default local single-user deployment mode is mTLS user authentication:

‎crates/openshell-cli/src/run.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,6 +1900,7 @@ pub async fn sandbox_exec_grpc(
19001900
// Make the streaming gRPC call.
19011901
let mut stream = client
19021902
.exec_sandbox(ExecSandboxRequest {
1903+
request_id: String::new(),
19031904
sandbox: name.to_string(),
19041905
workspace_scope: Some(openshell_core::proto::workspace_selector(
19051906
workspace.to_string(),
@@ -2303,6 +2304,7 @@ async fn sandbox_exec_interactive_grpc(
23032304
input_tx
23042305
.send(ExecSandboxInput {
23052306
payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest {
2307+
request_id: String::new(),
23062308
sandbox: sandbox.object_name().to_string(),
23072309
workspace_scope: Some(openshell_core::proto::workspace_selector(
23082310
(sandbox.object_workspace()).to_string(),

‎crates/openshell-sdk/src/client.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ impl OpenShellClient {
607607
/// call `exec_sandbox` directly.
608608
pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result<ExecResult> {
609609
let request = proto::ExecSandboxRequest {
610+
request_id: String::new(),
610611
sandbox: name.to_string(),
611612
workspace_scope: Some(proto::workspace_selector("default")),
612613
command: cmd.to_vec(),
@@ -1067,6 +1068,7 @@ impl WorkspaceScopedClient {
10671068
/// Run a command inside a sandbox and buffer stdout/stderr.
10681069
pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result<ExecResult> {
10691070
let request = proto::ExecSandboxRequest {
1071+
request_id: String::new(),
10701072
sandbox: name.to_string(),
10711073
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
10721074
command: cmd.to_vec(),

‎crates/openshell-server/src/grpc/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ impl OpenShell for OpenShellService {
430430
&self,
431431
request: Request<ExecSandboxRequest>,
432432
) -> Result<Response<Self::ExecSandboxStream>, Status> {
433-
sandbox::handle_exec_sandbox(&self.state, request).await
433+
mutation_replay::run(&self.state, request).await
434434
}
435435

436436
type ForwardTcpStream =

‎crates/openshell-server/src/grpc/mutation_replay.rs‎

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ struct Admission {
5656
#[serde(default)]
5757
protection: Option<Protection>,
5858
workspace_id: Option<String>,
59+
#[serde(default)]
60+
target_id: Option<String>,
5961
success: Option<Success>,
6062
completed_at_ms: Option<i64>,
6163
}
@@ -65,6 +67,8 @@ struct Protection {
6567
key_id: String,
6668
effective_payload_hash: String,
6769
effective_workspace_id: Option<String>,
70+
#[serde(default)]
71+
effective_target_id: Option<String>,
6872
}
6973

7074
/// Gateway-owned, in-memory input before hydration and interceptor modification.
@@ -78,19 +82,27 @@ pub(super) enum Success {
7882
Resource { id: String, version: u64 },
7983
Deletion { outcome: i32 },
8084
Ordinary(ordinary::Outcome),
85+
StreamTerminal,
8186
}
8287

8388
pub(super) struct Scope {
8489
name: String,
8590
workspace_id: Option<String>,
91+
// Name-addressed exec requests must remain bound to one sandbox instance.
92+
target_id: Option<String>,
8693
}
8794

8895
#[tonic::async_trait]
8996
pub(super) trait Mutation: Message + Default + Send + Sync + 'static {
90-
type Output: Message + Default + Send + 'static;
97+
type Output: Send + 'static;
9198
const METHOD: &'static str;
9299
const PROTECTED: bool = false;
100+
// Streaming producers, not returned stream handles, confirm completion.
101+
const DEFERRED: bool = false;
93102
fn request_id(&self) -> &str;
103+
fn canonical_message(&self) -> Result<DynamicMessage, Status> {
104+
decode_request_message(&format!("{}Request", Self::METHOD), self)
105+
}
94106
async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result<Scope, Status>;
95107
async fn execute(
96108
state: &Arc<ServerState>,
@@ -141,6 +153,7 @@ pub(super) async fn run<M: Mutation>(
141153
key_id: key.id(),
142154
effective_payload_hash: key.fingerprint(request.get_ref())?,
143155
effective_workspace_id: effective_scope.workspace_id,
156+
effective_target_id: effective_scope.target_id,
144157
}),
145158
)
146159
} else {
@@ -207,11 +220,12 @@ async fn execute_owned<M: Mutation>(
207220
protection: Option<Protection>,
208221
scope: Scope,
209222
) -> Result<Response<M::Output>, Status> {
210-
let mut admission = Admission {
223+
let admission = Admission {
211224
format_version: 1,
212225
payload_hash,
213226
protection,
214227
workspace_id: scope.workspace_id,
228+
target_id: scope.target_id,
215229
success: None,
216230
completed_at_ms: None,
217231
};
@@ -256,6 +270,7 @@ async fn execute_owned<M: Mutation>(
256270
));
257271
}
258272
if previous.workspace_id != admission.workspace_id
273+
|| previous.target_id != admission.target_id
259274
|| previous.protection != admission.protection
260275
{
261276
return Err(replay_unavailable());
@@ -302,32 +317,85 @@ async fn execute_owned<M: Mutation>(
302317
};
303318
// Any error or panic from here leaves the claim unresolved. Status codes
304319
// do not establish that a handler performed no effects.
320+
let completion = Completion {
321+
store: state.store.clone(),
322+
claim_id,
323+
key: key.into(),
324+
bucket: bucket.into(),
325+
version,
326+
admission: payload,
327+
};
328+
if M::DEFERRED {
329+
request.extensions_mut().insert(completion.clone());
330+
}
305331
let facts = ordinary::Facts::default();
306332
request.extensions_mut().insert(facts.clone());
307333
let mut response = M::execute(state, request).await?;
334+
if M::DEFERRED {
335+
return Ok(response);
336+
}
308337
response.extensions_mut().insert(facts);
309-
admission.success = Some(M::capture(&response)?);
338+
completion.complete(M::capture(&response)?).await?;
339+
return Ok(response);
340+
}
341+
Err(uncertain())
342+
}
343+
344+
/// Only the admitted owner can finalize this exact incarnation of a claim.
345+
/// A dropped finalizer leaves a permanent unresolved admission, never a lease.
346+
#[derive(Clone)]
347+
pub(super) struct Completion {
348+
store: Arc<Store>,
349+
claim_id: String,
350+
key: String,
351+
bucket: String,
352+
version: u64,
353+
admission: Vec<u8>,
354+
}
355+
356+
impl Completion {
357+
/// Reject replacement between admission authorization and relay creation.
358+
pub(super) fn ensure_target(&self, target_id: &str) -> Result<(), Status> {
359+
let admission: Admission =
360+
serde_json::from_slice(&self.admission).map_err(|_| uncertain())?;
361+
if admission
362+
.protection
363+
.as_ref()
364+
.and_then(|protection| protection.effective_target_id.as_deref())
365+
!= Some(target_id)
366+
{
367+
return Err(uncertain());
368+
}
369+
Ok(())
370+
}
371+
372+
pub(super) async fn stream_terminal(&self) -> Result<(), Status> {
373+
self.complete(Success::StreamTerminal).await
374+
}
375+
376+
async fn complete(&self, success: Success) -> Result<(), Status> {
377+
let mut admission: Admission =
378+
serde_json::from_slice(&self.admission).map_err(|_| uncertain())?;
379+
admission.success = Some(success);
310380
admission.completed_at_ms = Some(current_time_ms());
311381
let payload = serde_json::to_vec(&admission).map_err(|_| uncertain())?;
312382
if payload.len() > 64 * 1024 {
313383
return Err(uncertain());
314384
}
315-
state
316-
.store
385+
self.store
317386
.put_if(
318387
OBJECT_TYPE,
319-
&claim_id,
320-
key,
321-
bucket,
388+
&self.claim_id,
389+
&self.key,
390+
&self.bucket,
322391
&payload,
323392
None,
324-
WriteCondition::MatchResourceVersion(version),
393+
WriteCondition::MatchResourceVersion(self.version),
325394
)
326395
.await
327396
.map_err(|_| uncertain())?;
328-
return Ok(response);
397+
Ok(())
329398
}
330-
Err(uncertain())
331399
}
332400

333401
async fn prune_expired(store: &Store, bucket: &str) -> Result<bool, Status> {
@@ -377,12 +445,16 @@ fn validate_request_id(value: &str) -> Result<String, Status> {
377445
Ok(id.hyphenated().to_string())
378446
}
379447

380-
fn fingerprint<M: Mutation>(request: &M) -> Result<String, Status> {
448+
fn decode_request_message(name: &str, request: &impl Message) -> Result<DynamicMessage, Status> {
381449
let descriptor = DESCRIPTORS
382-
.get_message_by_name(&format!("openshell.v1.{}Request", M::METHOD))
450+
.get_message_by_name(&format!("openshell.v1.{name}"))
383451
.ok_or_else(|| Status::internal("mutation request descriptor missing"))?;
384-
let mut message = DynamicMessage::decode(descriptor, request.encode_to_vec().as_slice())
385-
.map_err(|_| Status::internal("decode mutation request"))?;
452+
DynamicMessage::decode(descriptor, request.encode_to_vec().as_slice())
453+
.map_err(|_| Status::internal("decode mutation request"))
454+
}
455+
456+
fn fingerprint<M: Mutation>(request: &M) -> Result<String, Status> {
457+
let mut message = request.canonical_message()?;
386458
message.clear_field_by_name("request_id");
387459
let value = serde_json::to_value(message)
388460
.map_err(|_| Status::internal("canonicalize mutation request"))?;
@@ -492,6 +564,7 @@ async fn named_scope(state: &ServerState, name: &str) -> Result<Scope, Status> {
492564
Ok(Scope {
493565
name: name.into(),
494566
workspace_id: Some(resource.object_id().into()),
567+
target_id: None,
495568
})
496569
}
497570

@@ -500,6 +573,7 @@ fn global_scope(state: &ServerState, principal: &Principal) -> Result<Scope, Sta
500573
Ok(Scope {
501574
name: String::new(),
502575
workspace_id: None,
576+
target_id: None,
503577
})
504578
}
505579

@@ -719,3 +793,4 @@ deletion_mutation!(
719793
mod tests;
720794

721795
pub(super) mod ordinary;
796+
pub(super) mod streaming;

0 commit comments

Comments
 (0)