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 Cargo.lock

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

33 changes: 22 additions & 11 deletions crates/minimal2/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,10 +445,6 @@ async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()>
let abs_path = paths::HostAbsPath::try_new(utf8_path)
.map_err(|e| eprintln!("Invalid project path: {e}"))?;

let username = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.ok();

let mut port_mappings = Vec::with_capacity(args.ingress.len());
for spec in &args.ingress {
match parse_ingress_mapping(spec) {
Expand All @@ -467,21 +463,23 @@ async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()>
}),
};

let record = sessions::Record {
id: sessions::SessionId::nil(),
// The daemon sources `username` from the authenticated SSH
// connection context; the client doesn't send it.
let config = minimald_rpc::SessionConfig {
name: args.name.clone(),
username,
project_path: abs_path,
network: args.network.into(),
policy,
status: Default::default(),
attrs: Default::default(),
};

let mut client = connect_daemon(global).await?;

use minimald_rpc::{CreateSession, CreateSessionRequest};
let req = CreateSessionRequest { record };
let req = CreateSessionRequest {
config,
contribution: Default::default(),
};
let resp = client
.oneshot_rpc::<CreateSession>(req)
.await
Expand All @@ -497,13 +495,26 @@ async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()>
return Err(());
}
};
// Today the daemon only ever produces `Ready` (the empty-
// contribution fast path). `Pending` lights up when Phase 2
// routing lands.
let id = match created {
minimald_rpc::CreateSessionResponse::Ready { id } => id,
minimald_rpc::CreateSessionResponse::Pending { .. } => {
eprintln!(
"CreateSession returned Pending, but the composition pipeline \
is not wired in this client yet",
);
return Err(());
}
};

println!("{}", created.id);
println!("{id}");

if args.attach {
// Chain into attach.
let attach_args = AttachArgs {
session: created.id.to_string(),
session: id.to_string(),
command: None,
};
return cmd_attach(global, attach_args).await;
Expand Down
1 change: 1 addition & 0 deletions crates/minimald-rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
constcat.workspace = true
chrono.workspace = true
paths.workspace = true
serde.workspace = true

sessions.workspace = true
Expand Down
141 changes: 136 additions & 5 deletions crates/minimald-rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,81 @@ impl OneshotSshRpc for GetSessionRecord {
type Response = GetSessionRecordResponse;
}

/// An RPC to create a new session based on the given record.
/// An RPC to create a new session.
///
/// Carries two parts: a [`SessionConfig`] (everything that doesn't
/// fit in a [`WireContribution`] — name, network, policy, attrs)
/// plus the client's Phase 1 [`WireContribution`]. Callers that
/// don't go through the composition pipeline (sftp / exec /
/// session-recovery) send `WireContribution::default()`.
pub struct CreateSession;

/// Session configuration that lives outside the composable
/// [`WireContribution`] — the user-supplied `name`, the project
/// path the session is built from, the network isolation mode, the
/// per-session networking policy, and free-form attrs.
///
/// `username` is deliberately *not* here: it comes from the SSH
/// connection context on the daemon side, never from the caller.
/// `id` and `status` are also out: id is allocated by the store,
/// status is managed by the manager actor.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionConfig {
/// User-supplied name. `None` is anonymous; the daemon may render
/// a short display name (e.g. `<user>-<project>-<uuid-suffix>`).
pub name: Option<String>,
/// Absolute host path the session is built from.
pub project_path: paths::HostAbsPath,
/// Network isolation mode.
#[serde(default)]
pub network: NetworkMode,
/// Per-session networking policy (egress + ingress).
#[serde(default)]
pub policy: SessionPolicy,
/// Free-form attributes (typed by the caller).
#[serde(default)]
pub attrs: std::collections::BTreeMap<String, String>,
}

/// The request for a [`CreateSession`] RPC.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreateSessionRequest {
pub record: sessions::Record,
/// Out-of-band session config.
pub config: SessionConfig,
/// Client-side Phase 1 contribution. Defaulted (empty) by
/// internal callers that aren't composing a session — e.g. sftp,
/// exec, session-recovery — so the daemon takes the empty-
/// contribution fast path and returns [`CreateSessionResponse::Ready`]
/// immediately.
#[serde(default)]
pub contribution: sessions::wire::request::WireContribution,
}

/// The response for a [`CreateSession`] RPC.
///
/// `Ready` is the only variant exercised today; `Pending` is
/// reserved for when daemon-side Phase 2 routing lands and the
/// daemon can ask the client to gate items that need approval.
/// Defining both up front locks the wire shape so adding the
/// Pending path later doesn't break callers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreateSessionResponse {
pub id: SessionId,
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CreateSessionResponse {
/// No items need user gating. The session id is the finalized
/// session — callers can immediately use it.
Ready {
/// Daemon-assigned session id.
id: SessionId,
},
/// Items need client-side gating. The session id is allocated
/// and the session is persisted (status reflects "in flight").
/// Client follows up with `SubmitVerdict` carrying the same id.
Pending {
/// Daemon-assigned session id; also embedded in `response`.
id: SessionId,
/// Pending items the client must gate.
response: sessions::wire::request::ContributionResponse,
},
}

impl OneshotSshRpc for CreateSession {
Expand Down Expand Up @@ -480,6 +542,7 @@ impl OneshotSshRpc for GetMeshStatus {
#[cfg(test)]
mod tests {
use super::*;
use sessions::wire::request::{ContributionResponse, WireContribution};

#[test]
fn policy_types_are_present_and_serializable() {
Expand Down Expand Up @@ -520,4 +583,72 @@ mod tests {
"got: {json}"
);
}

fn round_trip<T>(value: &T) -> T
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let json = serde_json::to_string(value).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}

#[test]
fn create_session_request_round_trips_with_explicit_contribution() {
let req = CreateSessionRequest {
config: SessionConfig {
name: Some("my-session".into()),
project_path: paths::HostAbsPath::try_new("/home/u/proj").unwrap(),
network: NetworkMode::OwnIp,
policy: SessionPolicy::default(),
attrs: [("color".to_string(), "blue".to_string())]
.into_iter()
.collect(),
},
contribution: WireContribution::default(),
};
assert_eq!(round_trip(&req), req);
}

#[test]
fn create_session_request_accepts_missing_contribution_field() {
// Wire payload from an internal caller that doesn't know about
// the composition pipeline: only `config` is set.
let raw = serde_json::json!({
"config": {
"name": null,
"project_path": "/proj",
"network": "host_net",
"policy": { "egress": null, "ingress": null },
"attrs": {},
},
});
let req: CreateSessionRequest = serde_json::from_value(raw).expect("deserialize");
assert_eq!(req.contribution, WireContribution::default());
}

#[test]
fn create_session_response_ready_round_trips() {
let id = SessionId::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let resp = CreateSessionResponse::Ready { id };
assert_eq!(round_trip(&resp), resp);
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains(r#""kind":"ready""#), "got: {json}");
}

#[test]
fn create_session_response_pending_round_trips() {
let id = SessionId::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let resp = CreateSessionResponse::Pending {
id,
response: ContributionResponse {
session_id: id,
vars: vec![],
patches: vec![],
lifecycle_hooks: vec![],
},
};
assert_eq!(round_trip(&resp), resp);
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains(r#""kind":"pending""#), "got: {json}");
}
}
27 changes: 8 additions & 19 deletions crates/minimald/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,35 +1220,24 @@ mod tests {
//! Tests that exercise the full ssh-exec stack against a real
//! `TestServer`: russh transport, `ConnectionHandler` dispatch,
//! `handle_exec`, and a real `/bin/sh -c` child.
use paths::HostAbsPath;
use sessions::SessionId;

use minimald_rpc::{CreateSession, CreateSessionRequest};
use minimald_rpc::CreateSession;

use crate::MINIMAL_SESSION_ID_ENV;
use crate::sessions::SessionKeyPredicate;
use crate::test_harness::{TestClient, TestServer};
use crate::test_harness::{TestClient, TestServer, create_session_req, unwrap_ready};

/// Creates a fresh session through the public CreateSession RPC
/// and returns its id, mirroring how a real client sets up state
/// before running commands against the session.
async fn fresh_session(client: &mut TestClient) -> SessionId {
client
.call::<CreateSession>(&CreateSessionRequest {
record: sessions::Record {
id: SessionId::nil(),
name: Some("exec-test".to_string()),
username: None,
project_path: HostAbsPath::try_new("/tmp").unwrap(),
network: sessions::NetworkMode::default(),
policy: Default::default(),
status: Default::default(),
attrs: Default::default(),
},
})
.await
.unwrap()
.id
unwrap_ready(
client
.call::<CreateSession>(&create_session_req("exec-test", "/tmp"))
.await
.unwrap(),
)
}

#[tokio::test]
Expand Down
Loading
Loading