From 0de8f6e856f6f3de0be624abbe8e5bfe8215d531 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 15:05:22 +0900 Subject: [PATCH 1/7] devguard, pty, runner: launch a prepared execution once under DevGuard (CSRG-U4) A prepared execution in a workspace that requires resource participation can now be launched instead of cancelled, when its owner has a launch helper: - codespace-devguard: BeginLaunch in a bounded owner session, sent once. Only a permit that comes with this attempt's committed record is a grant. AbandonLaunch reports a grant the owner holds no helper for. The helper's invocation comes from DevGuard's helper_command but is spawned by CodeSpace: it exposes the helper's arguments and its two private descriptors (permit carrier, transcript writer) and reads the transcript to its end. - codespace-pty: spawn_inheriting passes named descriptors through the pinned Codex PTY's inherited_fds path (Codex 0.154, unchanged). - runner: exclude_unrelated_except keeps exactly those two descriptors in the helper's pipe child; the PTY spawn makes them inheritable only for the spawn. PreparedExecution::launch_managed checks locally, sends BeginLaunch once, settles a lost answer with one lookup (cancel when still prepared, AbandonLaunch when committed and unclaimed), spawns the helper into the prepared slot and reads the transcript: READY then close is a running command; exec failure, refusal, failure or an end before READY is reported, the last three after the helper is reaped. The gateway and the worker do not use it yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/src/admission.rs | 173 +++- crates/devguard/src/launch.rs | 1156 ++++++++++++++++++++++++++ crates/devguard/src/lib.rs | 7 +- crates/pty/Cargo.lock | 1 + crates/pty/Cargo.toml | 1 + crates/pty/src/lib.rs | 160 +++- crates/runner/src/admission.rs | 535 ++++++++++-- crates/runner/src/descriptors.rs | 164 +++- crates/runner/src/launch.rs | 1340 ++++++++++++++++++++++++++++++ crates/runner/src/lib.rs | 2 + crates/runner/src/process.rs | 232 +++++- 11 files changed, 3653 insertions(+), 118 deletions(-) create mode 100644 crates/devguard/src/launch.rs create mode 100644 crates/runner/src/launch.rs diff --git a/crates/devguard/src/admission.rs b/crates/devguard/src/admission.rs index 0b3d757..34b8f10 100644 --- a/crates/devguard/src/admission.rs +++ b/crates/devguard/src/admission.rs @@ -2,8 +2,8 @@ //! //! DevGuard keeps attempts for the instance registered in the same session, so each request //! here is one bounded session: the owner's registration (see [`Owner::register`]), then one -//! `Admit`, `Lookup` or `Cancel` of the attempt, then close. Nothing here launches anything: -//! `BeginLaunch` and its permit are U4's. +//! `Admit`, `Lookup`, `Cancel` or `AbandonLaunch` of the attempt, then close. Nothing here +//! launches anything: `BeginLaunch`, its permit and the helper are in [`crate::launch`]. //! //! An [`Answer`] says only what the session established: //! @@ -159,6 +159,42 @@ pub struct Reservation { pub prepared_ttl_ms: u64, } +/// The scope a launch helper claimed for the attempt (CSRG-U4): on macOS the process group the +/// helper leads, named by its root, which becomes the executable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scope { + pub kind: ScopeKind, + pub root_pid: u32, +} + +/// How far DevGuard got with one resource's control in a launched scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Application { + Planned, + Applied, + Unsupported, + Failed, +} + +/// What DevGuard read back from the launched scope, per resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Applied { + pub cpu: Application, + pub memory: Application, + pub pids: Application, +} + +/// Why DevGuard returned an attempt's reservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Release { + /// The launched scope was observed to have ended. + ScopeTerminated, + /// The owner reported that it holds no helper for an unclaimed grant: nothing started. + NoHelperCreated, + /// The attempt belonged to an earlier boot. + PreviousBoot, +} + /// This owner's record of the attempt, without DevGuard's identities and digests. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Attempt { @@ -167,8 +203,14 @@ pub struct Attempt { pub denial: Option, pub plan: Option, pub reservation: Option, - /// Whether DevGuard reports resources applied to a launched scope. - pub applied: bool, + /// The scope a helper claimed, once one has. + pub scope: Option, + /// What DevGuard applied to the launched scope, once it bound it. + pub applied: Option, + /// Why the reservation was returned, once it was released. + pub release: Option, + /// Whether DevGuard lost track of the scope's members; this is sticky. + pub tracking_lost: bool, /// Whether DevGuard's record proves nothing was ever started for it. pub known_not_started: bool, } @@ -203,6 +245,20 @@ impl Owner { self.attempt_request(admission, Request::Cancel, effective_uid(), self.pid()) } + /// Report that this owner holds no helper for the attempt's launch grant and will start + /// none (CSRG-U4). DevGuard then releases an unclaimed grant as `no_helper_created`, which + /// proves the executable never started; a claimed grant has a helper and settles through + /// its scope instead. Only sound when it is true: the helper was never created, it was + /// reaped before READY, or the grant's permit never arrived. + pub fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer { + self.attempt_request( + admission, + Request::Abandon(reason), + effective_uid(), + self.pid(), + ) + } + pub(crate) fn admit_with(&self, admission: &Admission, uid: u32, pid: u32) -> Answer { self.attempt_request(admission, Request::Admit, uid, pid) } @@ -232,17 +288,21 @@ impl Owner { let Ok(fingerprint) = wire.fingerprint() else { return Answer::Refused(ErrorCode::InvalidRequest); }; - let (mut client, _) = - match self.open_with(authority_uid, admission_compatibility(), owner_pid) { - Ok(opened) => opened, - Err(registration) => return Answer::NotSent(registration), - }; + let compatibility = match request { + Request::Abandon(_) => crate::launch::launch_compatibility(), + Request::Admit | Request::Lookup | Request::Cancel => admission_compatibility(), + }; + let (mut client, _) = match self.open_with(authority_uid, compatibility, owner_pid) { + Ok(opened) => opened, + Err(registration) => return Answer::NotSent(registration), + }; registered(); let key = wire.key.clone(); let answered = match request { Request::Admit => client.admit(wire), Request::Lookup => client.lookup(key.clone()), Request::Cancel => client.cancel(key.clone()), + Request::Abandon(reason) => client.abandon_launch(key.clone(), reason.wire()), }; match answered { Ok(record) => self.attempt(&record, &key, &fingerprint), @@ -250,7 +310,7 @@ impl Owner { } } - fn wire(&self, admission: &Admission) -> Option { + pub(crate) fn wire(&self, admission: &Admission) -> Option { let wire = WireAdmission { key: AttemptKey { consumer_id: self.settings().consumer.clone(), @@ -264,7 +324,12 @@ impl Owner { } /// The record, when it is this owner's record of this attempt for this meaning. - fn attempt(&self, record: &AttemptRecord, key: &AttemptKey, fingerprint: &str) -> Answer { + pub(crate) fn attempt( + &self, + record: &AttemptRecord, + key: &AttemptKey, + fingerprint: &str, + ) -> Answer { let ours = self .registered_identity() .is_some_and(|identity| identity == record.owner); @@ -281,21 +346,58 @@ impl Owner { .prepare_deadline_ms .saturating_sub(reservation.prepared_at.monotonic_ms), }), - applied: record.applied.is_some(), + scope: record.scope.as_ref().map(|scope| Scope { + kind: scope_kind(scope.kind), + root_pid: scope.root.pid, + }), + applied: record.applied.as_ref().map(|applied| Applied { + cpu: application(applied.cpu), + memory: application(applied.memory), + pids: application(applied.pids), + }), + release: record.release_reason.map(|reason| match reason { + contract::ReleaseReason::ScopeTerminated => Release::ScopeTerminated, + contract::ReleaseReason::NoHelperCreated => Release::NoHelperCreated, + contract::ReleaseReason::PreviousBoot => Release::PreviousBoot, + }), + tracking_lost: record.tracking_lost, known_not_started: record.known_not_started(), }) } } +/// Why this owner holds no helper for a launch grant (CSRG-U4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Abandon { + /// Creating the helper failed, or the owner decided not to create it: no helper exists. + SpawnFailed, + /// The helper exited and was reaped before READY. + HelperExited, + /// The `BeginLaunch` answer carrying the permit never arrived. + GrantNotReceived, +} + +impl Abandon { + fn wire(self) -> devguard_client::protocol::AbandonReason { + use devguard_client::protocol::AbandonReason; + match self { + Self::SpawnFailed => AbandonReason::SpawnFailed, + Self::HelperExited => AbandonReason::HelperExited, + Self::GrantNotReceived => AbandonReason::GrantNotReceived, + } + } +} + #[derive(Debug, Clone, Copy)] enum Request { Admit, Lookup, Cancel, + Abandon(Abandon), } /// An error answer is definite, except DevGuard's client's own code for a transport failure. -fn answered_error(code: Code) -> Answer { +pub(crate) fn answered_error(code: Code) -> Answer { match code { Code::ResourceControlUnavailable => Answer::Unknown, code => Answer::Refused(code.into()), @@ -365,18 +467,31 @@ fn planned(resource: &contract::PlannedResource) -> Planned { } } +fn scope_kind(kind: contract::ScopeKind) -> ScopeKind { + match kind { + contract::ScopeKind::ObservedProcessGroup => ScopeKind::ObservedProcessGroup, + contract::ScopeKind::ContainedCgroup => ScopeKind::ContainedCgroup, + } +} + fn plan(plan: &contract::ExecutionPlan) -> Plan { Plan { - scope: match plan.scope_kind { - contract::ScopeKind::ObservedProcessGroup => ScopeKind::ObservedProcessGroup, - contract::ScopeKind::ContainedCgroup => ScopeKind::ContainedCgroup, - }, + scope: scope_kind(plan.scope_kind), cpu: planned(&plan.cpu), memory: planned(&plan.memory), pids: planned(&plan.pids), } } +fn application(state: contract::ApplicationState) -> Application { + match state { + contract::ApplicationState::Planned => Application::Planned, + contract::ApplicationState::Applied => Application::Applied, + contract::ApplicationState::Unsupported => Application::Unsupported, + contract::ApplicationState::Failed => Application::Failed, + } +} + fn phase(phase: contract::AttemptPhase) -> Phase { match phase { contract::AttemptPhase::Denied => Phase::Denied, @@ -393,7 +508,7 @@ fn phase(phase: contract::AttemptPhase) -> Phase { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::registration::RegistrationState; use crate::tests::{private_file, short_directory, CODES}; @@ -519,7 +634,7 @@ mod tests { // A scripted authority: replies, losses and delays a real one does not give at will. /// What the scripted authority does with the attempt request of session `n` (from 0). - enum Reply { + pub(crate) enum Reply { /// Answer this. With(Box), /// Read the request and close the connection: a lost reply. @@ -528,11 +643,11 @@ mod tests { Silent, } - type Script = Arc Reply + Send + Sync>; + pub(crate) type Script = Arc Reply + Send + Sync>; - struct Scripted { + pub(crate) struct Scripted { stop: Arc, - requests: Arc, + pub(crate) requests: Arc, worker: Option>, } @@ -563,7 +678,7 @@ mod tests { } } - fn scripted(socket: &Path, script: Script) -> Scripted { + pub(crate) fn scripted(socket: &Path, script: Script) -> Scripted { let listener = UnixListener::bind(socket).unwrap(); listener.set_nonblocking(true).unwrap(); let stop = Arc::new(AtomicBool::new(false)); @@ -596,7 +711,8 @@ mod tests { protocol: PROTOCOL_VERSION, authority: me(), caller: me(), - capabilities: admission_compatibility().required, + // What launch sessions need, a superset of what admission sessions need. + capabilities: crate::launch::launch_compatibility().required, max_frame_bytes: MAX_FRAME_BYTES, frame_deadline_ms: FRAME_DEADLINE_MS, max_sessions: MAX_SESSIONS, @@ -639,7 +755,7 @@ mod tests { } } - fn scripted_owner(dir: &Path, socket: &Path) -> Owner { + pub(crate) fn scripted_owner(dir: &Path, socket: &Path) -> Owner { let secret = "5ec2e7d0c0de".repeat(6); let credential = dir.join("scripted.secret"); if !credential.exists() { @@ -829,7 +945,10 @@ mod tests { panic!("not admitted"); }; assert_eq!(attempt.phase, Phase::Prepared); - assert!(!attempt.applied); + assert_eq!( + (attempt.scope, attempt.applied, attempt.release), + (None, None, None) + ); for _ in 1..4 { assert_eq!(admitted(&owner, &admission), Answer::Unknown); } @@ -866,7 +985,7 @@ mod tests { }; assert_eq!(attempt.phase, Phase::Prepared, "{attempt:?}"); assert_eq!(attempt.denial, None); - assert!(!attempt.applied && !attempt.known_not_started); + assert!(attempt.applied.is_none() && !attempt.known_not_started); let reservation = attempt.reservation.unwrap(); assert_eq!(reservation.quantities, request().requested); assert_eq!(reservation.prepared_ttl_ms, contract::PREPARED_TTL_MS); diff --git a/crates/devguard/src/launch.rs b/crates/devguard/src/launch.rs new file mode 100644 index 0000000..ecd1cf1 --- /dev/null +++ b/crates/devguard/src/launch.rs @@ -0,0 +1,1156 @@ +//! Managed launch of one admitted attempt by its registered owner (CSRG-U4). +//! +//! The owner commits the attempt's launch once with `BeginLaunch`, in a bounded session like +//! the others (`Register`, then the request). Only the first answer carries the attempt's +//! one-time permit, a [`Permit`] here, which cannot be cloned or printed. A lost answer is never +//! answered by asking again: [`LaunchAnswer::Unknown`] leaves the caller to look the attempt up +//! and settle it. +//! +//! [`Owner::helper`] turns the permit into one invocation of DevGuard's launch helper, +//! `devguard-launch`, through DevGuard's own `helper_command`: the helper's arguments, which +//! carry no secret, and two private descriptors DevGuard creates for it, the permit carrier and +//! the writer of the helper's transcript. Both are close-on-exec in this process. The owner +//! starts the helper as its own direct child through its own spawner, which must let exactly +//! those two descriptors reach the helper ([`HelperInvocation::descriptors`]); DevGuard's +//! `HelperCommand::spawn` is not used, so CodeSpace keeps its pipe and PTY spawners. Once the +//! spawn has returned, [`HelperInvocation::into_transcript`] closes this process's copies of +//! both descriptors and keeps the owner's end of the transcript. +//! +//! The helper presents the grant in a session of its own, from the owner's process as its +//! parent. The authority binds the helper's process group as the attempt's scope, applies and +//! reads back the policy, and authorizes the run; only then does the helper report READY, +//! consume nothing more and exec the executable, which keeps the helper's PID. The transcript +//! ([`Transcript::read`]) tells READY apart from what the executable did, and a failure before +//! READY from one after it. +//! +//! Nothing here keeps DevGuard's messages, and the permit never leaves the invocation: it is +//! written into the carrier and dropped. + +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::os::fd::RawFd; +use std::os::unix::fs::MetadataExt; +use std::path::{Component, Path, PathBuf}; + +use devguard_client::launch::{ + helper_command, HelperCommand, HelperPhase, HelperReport, HelperTicket, LaunchOutcome, +}; +use devguard_contract as contract; +use devguard_contract::{AttemptKey, Compatibility, Secret, PROTOCOL_VERSION}; + +use crate::admission::{Admission, Answer, Phase}; +use crate::registration::{Owner, Registration}; +use crate::{effective_uid, ErrorCode}; + +/// The attempt's one-time launch permit. Only the first `BeginLaunch` answer carries it. It +/// cannot be cloned or printed, and building the helper invocation consumes it. +pub struct Permit(Secret); + +impl Permit { + /// A permit from its 64 lowercase hexadecimal digits, as a test authority would grant it. + /// It authorizes nothing by itself: an authority accepts only the permit of its own grant. + pub fn from_text(text: &str) -> Option { + Secret::new(text.to_owned()).ok().map(Self) + } +} + +impl std::fmt::Debug for Permit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Permit([REDACTED])") + } +} + +/// What a `BeginLaunch` session established. +#[derive(Debug)] +pub enum LaunchAnswer { + /// DevGuard committed the launch of this owner's attempt, and this is its only permit. + Granted { + attempt: crate::admission::Attempt, + permit: Permit, + }, + /// DevGuard answered with this error. Each of its requests is one journal transaction that + /// commits only before a record is answered, so nothing was committed. + Refused(ErrorCode), + /// The session ended before `BeginLaunch` was written: nothing was asked. + NotSent(Registration), + /// `BeginLaunch` may have reached DevGuard and no permit came back: a timeout, a lost reply, + /// an EOF, DevGuard's transport code, an answer for another attempt, owner or meaning, or an + /// answer without a permit. Whether the launch was committed is not known; asking again + /// would at best return the committed record without its permit. + Unknown, +} + +/// Launch sessions need what admission needs, and fenced launch. +pub(crate) fn launch_compatibility() -> Compatibility { + Compatibility { + minimum_protocol: PROTOCOL_VERSION, + maximum_protocol: PROTOCOL_VERSION, + required: BTreeSet::from([ + contract::Capability::StaticControlReservations, + contract::Capability::DurableAdmission, + contract::Capability::FencedLaunch, + ]), + } +} + +impl Owner { + /// Commit the launch of the admitted attempt, once. A caller never sends it twice for one + /// attempt: after [`LaunchAnswer::Unknown`] it looks the attempt up instead. + pub fn begin_launch(&self, admission: &Admission) -> LaunchAnswer { + self.begin_launch_after(admission, effective_uid(), self.pid(), &|| {}) + } + + /// [`Self::begin_launch`], calling `registered` between the registration and the request. + pub(crate) fn begin_launch_after( + &self, + admission: &Admission, + authority_uid: u32, + owner_pid: u32, + registered: &dyn Fn(), + ) -> LaunchAnswer { + let Some(wire) = self.wire(admission) else { + return LaunchAnswer::Refused(ErrorCode::InvalidRequest); + }; + let Ok(fingerprint) = wire.fingerprint() else { + return LaunchAnswer::Refused(ErrorCode::InvalidRequest); + }; + let (mut client, _) = match self.open_with(authority_uid, launch_compatibility(), owner_pid) + { + Ok(opened) => opened, + Err(registration) => return LaunchAnswer::NotSent(registration), + }; + registered(); + let grant = match client.begin_launch(wire.key.clone()) { + Ok(grant) => grant, + Err(error) => { + return match crate::admission::answered_error(error.code) { + Answer::Refused(code) => LaunchAnswer::Refused(code), + _ => LaunchAnswer::Unknown, + } + } + }; + // Only this owner's committed record of this attempt and meaning, with its permit, is a + // grant. A permit that came with anything else is dropped unused. + match ( + self.attempt(&grant.attempt, &wire.key, &fingerprint), + grant.permit, + ) { + (Answer::Attempt(attempt), Some(permit)) if attempt.phase == Phase::LaunchCommitted => { + LaunchAnswer::Granted { + attempt, + permit: Permit(permit), + } + } + _ => LaunchAnswer::Unknown, + } + } + + /// The launch helper's invocation for this owner's grant of `admission`: `helper` runs + /// `program` with `args` once the authority at this owner's socket authorizes it. + pub fn helper( + &self, + helper: &Path, + admission: &Admission, + permit: Permit, + program: &Path, + args: &[OsString], + ) -> Result { + let ticket = LaunchTicket { + endpoint: self.settings().socket.clone(), + consumer: self.consumer().to_owned(), + generation: self.generation().to_owned(), + attempt_id: admission.attempt_id.clone(), + instance_id: self.instance_id().to_owned(), + }; + HelperInvocation::new(helper, &ticket, permit, program, args) + } +} + +/// Whose grant a helper presents, and where. None of it is secret. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchTicket { + /// The authority's socket the owner's sessions use. + pub endpoint: PathBuf, + pub consumer: String, + pub generation: String, + pub attempt_id: String, + /// The owner's registered instance. + pub instance_id: String, +} + +/// One grant's helper invocation, ready for the owner's own spawner. It owns this process's +/// copies of the permit carrier and the transcript writer, both close-on-exec, until +/// [`Self::into_transcript`]; dropping it closes them too. +pub struct HelperInvocation { + helper: PathBuf, + args: Vec, + descriptors: [RawFd; 2], + /// Holds the two descriptors; never spawned. + _command: HelperCommand, + report: HelperReport, +} + +impl std::fmt::Debug for HelperInvocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HelperInvocation") + .field("helper", &self.helper) + .field("args", &self.args) + .field("descriptors", &self.descriptors) + .finish_non_exhaustive() + } +} + +impl HelperInvocation { + /// Build the invocation for `ticket` through DevGuard's `helper_command`, which creates the + /// two descriptors above the standard three, close-on-exec, under its process-wide guard. + pub fn new( + helper: &Path, + ticket: &LaunchTicket, + permit: Permit, + program: &Path, + args: &[OsString], + ) -> Result { + let ticket = HelperTicket { + endpoint: ticket.endpoint.clone(), + key: AttemptKey { + consumer_id: ticket.consumer.clone(), + consumer_generation: ticket.generation.clone(), + attempt_id: ticket.attempt_id.clone(), + }, + instance_id: ticket.instance_id.clone(), + }; + let (command, report) = helper_command(helper, &ticket, &permit.0, program, args) + .map_err(|error| ErrorCode::from(error.code))?; + drop(permit); + let args: Vec = command.args().map(OsStr::to_owned).collect(); + let descriptor = |flag: &str| -> Option { + let at = args.iter().position(|arg| arg == flag)?; + args.get(at + 1)?.to_str()?.parse().ok() + }; + // DevGuard states them as the helper's `--permit-fd` and `--report-fd`. + let (Some(permit_fd), Some(report_fd)) = + (descriptor("--permit-fd"), descriptor("--report-fd")) + else { + return Err(ErrorCode::InvalidRequest); + }; + Ok(Self { + helper: helper.to_owned(), + args, + descriptors: [permit_fd, report_fd], + _command: command, + report, + }) + } + + /// The helper's absolute path. + pub fn helper(&self) -> &Path { + &self.helper + } + + /// The helper's arguments, ending with `--`, the program and its arguments. None is secret. + pub fn args(&self) -> &[OsString] { + &self.args + } + + /// The two descriptors the helper must inherit, at these numbers, and the only ones besides + /// its standard three: the permit carrier, then the transcript writer. Both are + /// close-on-exec in this process. + pub fn descriptors(&self) -> [RawFd; 2] { + self.descriptors + } + + /// Clear close-on-exec on both descriptors in this process, for a spawner that cannot clear + /// it in its child (the pinned Codex PTY). They stay inheritable until + /// [`Self::into_transcript`] closes them, so every other spawner in this process must keep + /// unrelated descriptors out of its children meanwhile, as CodeSpace's do (#79). + pub fn make_inheritable(&self) -> std::io::Result<()> { + for fd in self.descriptors { + // SAFETY: fcntl reads and changes only the flags of a descriptor this invocation + // owns. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 + { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + } + + /// Close this process's copies of the permit carrier and the transcript writer, and keep + /// the owner's end of the transcript. Call it as soon as the spawn has returned, whether or + /// not it succeeded, so the transcript ends when the helper execs or exits. + pub fn into_transcript(self) -> Transcript { + Transcript(self.report) + } +} + +/// The owner's end of one helper's transcript. +pub struct Transcript(HelperReport); + +/// Where a helper failed before presenting the grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelperStage { + /// Leading its own process group. + Scope, + /// Re-executing under the utility QoS clamp. + Clamp, + /// Protecting the transcript from the executable. + Report, + /// Reading the permit. + Permit, + /// Reaching the authority. + Connect, + Other, +} + +/// How a helper's transcript ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscriptEnd { + /// READY, then the transcript closed: the helper exec'd the executable, which kept its PID. + /// A helper killed between READY and its exec looks the same, so the exit status still + /// decides what the executable did. + Started, + /// READY, then the exec failed with this errno; the helper exits 126, or 127 for `ENOENT`. + ExecFailed { errno: i32 }, + /// The authority did not authorize the helper, or its answer was lost: the helper never + /// attempted the executable. It may have claimed the grant first. + Refused { code: ErrorCode }, + /// The helper could not present the grant, so it did not claim it, and never attempted the + /// executable. + Failed { stage: HelperStage }, + /// The transcript closed without READY or a report: the helper ended before READY, which it + /// writes before its exec, so the executable never ran. + EndedBeforeReady, + /// READY without a final report, or a transcript that is not well formed: whether the + /// executable started is not known. + Uncertain, +} + +impl Transcript { + /// Read the transcript until the helper closes it: when its executable starts or when the + /// helper exits. This blocks; the helper's own deadlines bound it up to READY. + pub fn read(self) -> TranscriptEnd { + match self.0.read_until_closed() { + Ok((outcome, _)) => transcript_end(outcome), + Err(_) => TranscriptEnd::Uncertain, + } + } +} + +fn transcript_end(outcome: LaunchOutcome) -> TranscriptEnd { + match outcome { + LaunchOutcome::Started => TranscriptEnd::Started, + LaunchOutcome::ExecFailed { errno } => TranscriptEnd::ExecFailed { errno }, + LaunchOutcome::NotStarted(HelperPhase::Refused { code, .. }) => { + TranscriptEnd::Refused { code: code.into() } + } + LaunchOutcome::NotStarted(HelperPhase::Failed { stage, .. }) => TranscriptEnd::Failed { + stage: match stage.as_str() { + "scope" => HelperStage::Scope, + "clamp" => HelperStage::Clamp, + "report" => HelperStage::Report, + "permit" => HelperStage::Permit, + "connect" => HelperStage::Connect, + _ => HelperStage::Other, + }, + }, + LaunchOutcome::NotStarted(_) => TranscriptEnd::Uncertain, + LaunchOutcome::Lost { ready: false } => TranscriptEnd::EndedBeforeReady, + LaunchOutcome::Lost { ready: true } => TranscriptEnd::Uncertain, + } +} + +/// Why a launch helper cannot be used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelperProblem { + /// The path is not absolute, or names `.` or `..`. + NotAbsolute, + /// Nothing usable is there: missing, a link, or not a regular file. + NotAFile, + /// The file is not executable. + NotExecutable, + /// Another user than this one or root owns the file or a directory on its path, or the + /// group or others can write it, so they could replace the helper this owner hands its + /// permits to. + NotPrivate, +} + +impl HelperProblem { + pub fn describe(self) -> &'static str { + match self { + Self::NotAbsolute => "the launch helper path is not absolute", + Self::NotAFile => "the launch helper is not a regular file", + Self::NotExecutable => "the launch helper is not executable", + Self::NotPrivate => { + "the launch helper, or a directory on its path, can be replaced by another user" + } + } + } +} + +/// Whether `path` can serve as the launch helper: an absolute path, through real directories +/// owned by this user or root that the group and others cannot write (apart from a root-owned +/// sticky `/tmp`), to a regular, executable file owned by this user or root that the group and +/// others cannot write. The owner hands this file each permit, so no one else may swap it. +pub fn check_helper(path: &Path) -> Result<(), HelperProblem> { + let (true, Some(parent)) = (path.is_absolute(), path.parent()) else { + return Err(HelperProblem::NotAbsolute); + }; + let uid = effective_uid(); + let owned = |meta: &std::fs::Metadata| meta.uid() == uid || meta.uid() == 0; + let mut current = PathBuf::new(); + for component in parent.components() { + match component { + Component::RootDir => current.push("/"), + Component::Normal(name) => current.push(name), + _ => return Err(HelperProblem::NotAbsolute), + } + let meta = std::fs::symlink_metadata(¤t).map_err(|_| HelperProblem::NotPrivate)?; + let shared_tmp = matches!(current.to_str(), Some("/tmp" | "/private/tmp")) + && meta.uid() == 0 + && meta.mode() & 0o1000 != 0; + if !meta.is_dir() || !owned(&meta) || (!shared_tmp && meta.mode() & 0o022 != 0) { + return Err(HelperProblem::NotPrivate); + } + } + if path + .file_name() + .is_none_or(|name| name == "." || name == "..") + { + return Err(HelperProblem::NotAbsolute); + } + let meta = std::fs::symlink_metadata(path).map_err(|_| HelperProblem::NotAFile)?; + if !meta.is_file() { + return Err(HelperProblem::NotAFile); + } + if !owned(&meta) || meta.mode() & 0o022 != 0 { + return Err(HelperProblem::NotPrivate); + } + if meta.mode() & 0o111 == 0 { + return Err(HelperProblem::NotExecutable); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::admission::tests::admission; + use crate::tests::{private_file, short_directory}; + use crate::{OwnerCredential, OwnerSettings}; + use std::os::unix::fs::PermissionsExt; + + /// DevGuard's launch helper built from the pinned source, named by + /// `CODESPACE_DEVGUARD_LAUNCH_BIN`. Without it a test that needs it is skipped, unless + /// `CODESPACE_REQUIRE_DEVGUARD_BINS` says the stage built it, which then fails the test. + pub(crate) fn helper_bin() -> Option { + match std::env::var_os("CODESPACE_DEVGUARD_LAUNCH_BIN") { + Some(path) => Some(PathBuf::from(path)), + None if std::env::var_os("CODESPACE_REQUIRE_DEVGUARD_BINS").is_some() => { + panic!("CODESPACE_DEVGUARD_LAUNCH_BIN is required by this stage") + } + None => { + eprintln!("skipped: CODESPACE_DEVGUARD_LAUNCH_BIN is not set"); + None + } + } + } + + #[test] + fn a_permit_never_shows_itself() { + let text = "ab".repeat(32); + let permit = Permit::from_text(&text).unwrap(); + assert_eq!(format!("{permit:?}"), "Permit([REDACTED])"); + assert!(Permit::from_text("short").is_none()); + let answer = LaunchAnswer::Granted { + attempt: crate::admission::Attempt { + phase: Phase::LaunchCommitted, + denial: None, + plan: None, + reservation: None, + scope: None, + applied: None, + release: None, + tracking_lost: false, + known_not_started: false, + }, + permit, + }; + assert!(!format!("{answer:?}").contains(&text)); + } + + #[test] + fn transcripts_keep_ready_apart_from_what_the_executable_did() { + let refused = HelperPhase::Refused { + code: contract::ErrorCode::InvalidTransition, + message: "a message that is never kept".into(), + }; + let failed = |stage: &str| HelperPhase::Failed { + stage: stage.into(), + message: "never kept".into(), + }; + let cases = [ + (LaunchOutcome::Started, TranscriptEnd::Started), + ( + LaunchOutcome::ExecFailed { errno: 8 }, + TranscriptEnd::ExecFailed { errno: 8 }, + ), + ( + LaunchOutcome::NotStarted(refused), + TranscriptEnd::Refused { + code: ErrorCode::InvalidTransition, + }, + ), + ( + LaunchOutcome::NotStarted(failed("permit")), + TranscriptEnd::Failed { + stage: HelperStage::Permit, + }, + ), + ( + LaunchOutcome::NotStarted(failed("elsewhere")), + TranscriptEnd::Failed { + stage: HelperStage::Other, + }, + ), + ( + LaunchOutcome::Lost { ready: false }, + TranscriptEnd::EndedBeforeReady, + ), + ( + LaunchOutcome::Lost { ready: true }, + TranscriptEnd::Uncertain, + ), + ]; + for (outcome, end) in cases { + assert_eq!(transcript_end(outcome), end); + } + } + + fn owner(dir: &Path) -> Owner { + let credential = private_file(dir, "owner.secret", "5ec2".repeat(16).as_bytes()); + Owner::new( + OwnerSettings { + socket: dir.join("authority.sock"), + consumer: "codespace".into(), + generation: "g1".into(), + }, + OwnerCredential::File(credential), + ) + } + + #[test] + fn an_invocation_names_its_two_descriptors_and_carries_no_secret() { + let dir = short_directory(); + let owner = owner(dir.path()); + let text = "c0".repeat(32); + let invocation = owner + .helper( + Path::new("/usr/libexec/devguard-launch"), + &admission("cs-attempt-h", &["/bin/echo"]), + Permit::from_text(&text).unwrap(), + Path::new("/bin/echo"), + &["hi".into()], + ) + .unwrap(); + let [permit_fd, report_fd] = invocation.descriptors(); + assert!(permit_fd > 2 && report_fd > 2 && permit_fd != report_fd); + for fd in invocation.descriptors() { + // SAFETY: reads the flags of a descriptor the invocation owns. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + assert!(flags >= 0 && flags & libc::FD_CLOEXEC != 0, "{fd}"); + } + let args: Vec = invocation + .args() + .iter() + .map(|arg| arg.to_str().unwrap().to_owned()) + .collect(); + let tail = ["--", "/bin/echo", "hi"].map(String::from); + assert!(args.ends_with(&tail), "{args:?}"); + assert!(args.contains(&"cs-attempt-h".to_owned())); + assert!(args.contains(&owner.instance_id().to_owned())); + let shown = format!("{invocation:?} {args:?}"); + assert!(!shown.contains(&text), "{shown}"); + // Inheritable on request, and closed with the invocation. + invocation.make_inheritable().unwrap(); + for fd in invocation.descriptors() { + // SAFETY: as above. + assert_eq!( + unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC, + 0 + ); + } + let transcript = invocation.into_transcript(); + // The writer is closed, so the transcript ends at once without READY. + assert_eq!(transcript.read(), TranscriptEnd::EndedBeforeReady); + // A relative helper or program is refused before anything is created. + for (helper, program) in [ + ("devguard-launch", "/bin/echo"), + ("/x/devguard-launch", "echo"), + ] { + assert_eq!( + owner + .helper( + Path::new(helper), + &admission("cs-attempt-h", &["/bin/echo"]), + Permit::from_text(&text).unwrap(), + Path::new(program), + &[], + ) + .unwrap_err(), + ErrorCode::InvalidRequest + ); + } + } + + #[test] + fn only_a_private_executable_file_serves_as_the_helper() { + let dir = short_directory(); + let base = dir.path().join("bin"); + std::fs::create_dir(&base).unwrap(); + std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap(); + let file = |name: &str, mode: u32| { + let path = base.join(name); + std::fs::write(&path, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path + }; + assert_eq!(check_helper(&file("good", 0o700)), Ok(())); + assert_eq!(check_helper(&file("ro", 0o500)), Ok(())); + assert_eq!( + check_helper(&file("plain", 0o600)), + Err(HelperProblem::NotExecutable) + ); + assert_eq!( + check_helper(&file("group", 0o770)), + Err(HelperProblem::NotPrivate) + ); + let link = base.join("link"); + std::os::unix::fs::symlink(base.join("good"), &link).unwrap(); + assert_eq!(check_helper(&link), Err(HelperProblem::NotAFile)); + assert_eq!( + check_helper(&base.join("missing")), + Err(HelperProblem::NotAFile) + ); + assert_eq!(check_helper(&base), Err(HelperProblem::NotAFile)); + assert_eq!( + check_helper(Path::new("bin/good")), + Err(HelperProblem::NotAbsolute) + ); + assert_eq!( + check_helper(&base.join("../bin/good")), + Err(HelperProblem::NotAbsolute) + ); + // A directory others can write lets them replace the file. + let open = dir.path().join("open"); + std::fs::create_dir(&open).unwrap(); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + let exposed = open.join("helper"); + std::fs::write(&exposed, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&exposed, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(check_helper(&exposed), Err(HelperProblem::NotPrivate)); + } + + // A scripted authority: answers a real one does not give at will. + + use crate::admission::tests::{scripted, scripted_owner, Reply}; + use crate::registration::RegistrationState; + use devguard_client::protocol::{LaunchGrant, Request as Wire, Response, WireError}; + use devguard_contract::{AttemptRecord, InstanceIdentity}; + use std::sync::Arc; + + fn launched(owner: &Owner, admission: &Admission) -> LaunchAnswer { + // DevGuard reads the credential within 250 ms (see `past_the_credential`). + crate::tests::past_the_credential( + |answer: &LaunchAnswer| { + matches!(answer, LaunchAnswer::NotSent(registration) + if registration.state == RegistrationState::CredentialUnavailable) + }, + || owner.begin_launch(admission), + ) + } + + /// The committed record DevGuard keeps for `owner`'s attempt `key` with `fingerprint`. + fn committed(key: &AttemptKey, fingerprint: &str, owner: &InstanceIdentity) -> AttemptRecord { + AttemptRecord { + key: key.clone(), + request_fingerprint: fingerprint.to_owned(), + owner: owner.clone(), + policy_revision: "revision".into(), + phase: contract::AttemptPhase::LaunchCommitted, + reservation: None, + plan: None, + scope: None, + applied: None, + denial: None, + tracking_lost: false, + release_reason: None, + } + } + + #[test] + fn only_a_permit_with_this_attempts_committed_record_is_a_grant() { + let dir = short_directory(); + let text = "9a".repeat(32); + let probe = scripted_owner(dir.path(), &dir.path().join("unused.sock")); + let wire = probe.wire(&admission("cs-l1", &["/bin/echo"])).unwrap(); + let fingerprint = wire.fingerprint().unwrap(); + // Session by session: a grant; a grant without its permit, as for a replay; a grant + // for another attempt; one for another meaning; an uncommitted record with a permit. + let permit = text.clone(); + let socket = dir.path().join("grants.sock"); + let _authority = scripted( + &socket, + Arc::new(move |session, request, owner| { + let Wire::BeginLaunch { key } = request else { + return Reply::Close; + }; + let mut record = committed(key, &fingerprint, owner); + let mut permit = Secret::new(permit.clone()).ok(); + match session { + 0 => {} + 1 => permit = None, + 2 => record.key.attempt_id = "cs-other".into(), + 3 => record.request_fingerprint = "0".repeat(64), + _ => record.phase = contract::AttemptPhase::Prepared, + } + Reply::With(Box::new(Response::LaunchGranted(LaunchGrant { + attempt: record, + permit, + }))) + }), + ); + let owner = scripted_owner(dir.path(), &socket); + let admission = admission("cs-l1", &["/bin/echo"]); + let LaunchAnswer::Granted { attempt, permit } = launched(&owner, &admission) else { + panic!("not granted"); + }; + assert_eq!(attempt.phase, Phase::LaunchCommitted); + assert_eq!(permit.0.expose(), text); + for _ in 1..5 { + assert!(matches!( + launched(&owner, &admission), + LaunchAnswer::Unknown + )); + } + } + + #[test] + fn a_refusal_commits_nothing_and_a_lost_answer_is_unknown() { + let dir = short_directory(); + for code in crate::tests::CODES { + let socket = dir.path().join(format!("{code:?}.sock")); + let _authority = scripted( + &socket, + Arc::new(move |_, _, _| { + Reply::With(Box::new(Response::Error(WireError { + code, + message: "a message that is never kept".into(), + }))) + }), + ); + let answer = launched( + &scripted_owner(dir.path(), &socket), + &admission("cs-l2", &["/bin/echo"]), + ); + match code { + // DevGuard's client's own code for a transport failure. + contract::ErrorCode::ResourceControlUnavailable => { + assert!(matches!(answer, LaunchAnswer::Unknown), "{answer:?}") + } + code => assert!( + matches!(answer, LaunchAnswer::Refused(refused) if refused == code.into()), + "{answer:?}" + ), + } + assert!(!format!("{answer:?}").contains("never kept")); + } + for (name, silent) in [("silent", true), ("lost", false)] { + let socket = dir.path().join(format!("{name}.sock")); + let authority = scripted( + &socket, + Arc::new(move |_, _, _| if silent { Reply::Silent } else { Reply::Close }), + ); + let answer = launched( + &scripted_owner(dir.path(), &socket), + &admission("cs-l3", &["/bin/echo"]), + ); + assert!( + matches!(answer, LaunchAnswer::Unknown), + "{name}: {answer:?}" + ); + // The request reached the authority: it may have committed. + assert_eq!( + authority.requests.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + } + // Without a usable credential no session opens, so nothing was asked. + let unusable = Owner::new( + OwnerSettings { + socket: dir.path().join("absent.sock"), + consumer: "codespace".into(), + generation: "g1".into(), + }, + OwnerCredential::Unavailable, + ); + assert!(matches!( + unusable.begin_launch(&admission("cs-l4", &["/bin/echo"])), + LaunchAnswer::NotSent(registration) + if registration.state == RegistrationState::CredentialUnavailable + )); + } + + // DevGuard's fixture authority with native host evidence and its launch helper, as on macOS. + + #[cfg(target_os = "macos")] + mod native { + use super::*; + use crate::admission::tests::request; + use crate::admission::{Abandon, Application, Release}; + use crate::registration::tests::native::{provision, Provisioned}; + use devguard_contract::Budget; + use std::os::unix::process::CommandExt; + use std::process::{Child, Command, Stdio}; + use std::time::{Duration, Instant}; + + fn admitting() -> Provisioned { + let provisioned = provision(2); + provisioned + .authority + .wait_until_admitting(Duration::from_secs(10)) + .unwrap(); + provisioned + } + + fn admitted(owner: &Owner, admission: &Admission) { + let answer = crate::tests::past_the_credential( + |answer: &Answer| matches!(answer, Answer::NotSent(_)), + || owner.admit(admission), + ); + assert!( + matches!(answer, Answer::Attempt(attempt) if attempt.phase == Phase::Prepared), + "{answer:?}" + ); + } + + fn granted(owner: &Owner, admission: &Admission) -> Permit { + match launched(owner, admission) { + LaunchAnswer::Granted { attempt, permit } => { + assert_eq!(attempt.phase, Phase::LaunchCommitted); + assert_eq!(attempt.scope, None); + permit + } + other => panic!("not granted: {other:?}"), + } + } + + fn looked_up(owner: &Owner, admission: &Admission) -> crate::admission::Attempt { + match crate::tests::past_the_credential( + |answer: &Answer| matches!(answer, Answer::NotSent(_)), + || owner.lookup(admission), + ) { + Answer::Attempt(attempt) => attempt, + other => panic!("not found: {other:?}"), + } + } + + fn abandoned( + owner: &Owner, + admission: &Admission, + reason: Abandon, + ) -> crate::admission::Attempt { + match crate::tests::past_the_credential( + |answer: &Answer| matches!(answer, Answer::NotSent(_)), + || owner.abandon_launch(admission, reason), + ) { + Answer::Attempt(attempt) => attempt, + other => panic!("no record: {other:?}"), + } + } + + /// Start the helper as a direct child of this process, passing exactly its two + /// descriptors, as CodeSpace's own spawners do. + fn spawn(invocation: &HelperInvocation, stdout: Stdio) -> Child { + let mut command = Command::new(invocation.helper()); + command + .args(invocation.args()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(Stdio::null()); + let keep = invocation.descriptors(); + // SAFETY: the step runs in the child and only clears close-on-exec with fcntl. + unsafe { + command.pre_exec(move || { + for fd in keep { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 + || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 + { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } + command.spawn().unwrap() + } + + /// The attempt once DevGuard has released it, which its reconciler does within about a + /// second of observing the scope's end. + fn released(owner: &Owner, admission: &Admission) -> crate::admission::Attempt { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let attempt = looked_up(owner, admission); + if attempt.phase == Phase::Released || Instant::now() > deadline { + return attempt; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn charged(provisioned: &Provisioned) -> Budget { + provisioned.authority.committed().unwrap() + } + + #[test] + fn a_granted_launch_runs_the_executable_once_in_its_bound_scope() { + let Some(helper) = helper_bin() else { return }; + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-launch-1", &["/bin/echo", "hi"]); + admitted(&owner, &admission); + let permit = granted(&owner, &admission); + // DevGuard's grant is one-time: another BeginLaunch answers without a permit, which + // is never taken for a grant. CodeSpace's own launch never sends one. + assert!(matches!( + launched(&owner, &admission), + LaunchAnswer::Unknown + )); + let invocation = owner + .helper( + &helper, + &admission, + permit, + Path::new("/bin/echo"), + &["hi".into()], + ) + .unwrap(); + let child = spawn(&invocation, Stdio::piped()); + let root = child.id(); + assert_eq!(invocation.into_transcript().read(), TranscriptEnd::Started); + // The helper's process group is the scope, rooted at the helper's PID, which the + // executable kept, and every resource was applied before READY. + let running = looked_up(&owner, &admission); + assert!( + matches!(running.phase, Phase::RunAuthorized | Phase::Released), + "{running:?}" + ); + assert_eq!(running.scope.map(|scope| scope.root_pid), Some(root)); + let applied = running.applied.unwrap(); + assert_eq!( + (applied.cpu, applied.memory, applied.pids), + ( + Application::Applied, + Application::Applied, + Application::Applied + ) + ); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"hi\n"); + // Once the scope ended, DevGuard released it through its scope, not as unstarted. + let ended = released(&owner, &admission); + assert_eq!( + (ended.phase, ended.release, ended.known_not_started), + (Phase::Released, Some(Release::ScopeTerminated), false) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn a_lost_grant_is_found_committed_and_released_as_never_received() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-launch-2", &["/bin/echo"]); + admitted(&owner, &admission); + // Once the session is registered the authority's lock is held past the client's + // deadline, so BeginLaunch waits behind it and the client gives up. + let holder = std::sync::Mutex::new(None); + let answer = crate::tests::past_the_credential( + |answer: &LaunchAnswer| matches!(answer, LaunchAnswer::NotSent(_)), + || { + owner.begin_launch_after( + &admission, + effective_uid(), + std::process::id(), + &|| { + let held = provisioned + .authority + .hold_authority(Duration::from_millis(900)); + *holder.lock().unwrap() = Some(held); + }, + ) + }, + ); + if let Some(held) = holder.lock().unwrap().take() { + held.join().unwrap(); + } + assert!(matches!(answer, LaunchAnswer::Unknown), "{answer:?}"); + // DevGuard committed it after the client gave up: committed, and no helper claimed it. + let deadline = Instant::now() + Duration::from_secs(2); + let found = loop { + let attempt = looked_up(&owner, &admission); + if attempt.phase != Phase::Prepared || Instant::now() > deadline { + break attempt; + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert_eq!((found.phase, found.scope), (Phase::LaunchCommitted, None)); + // The permit never arrived, so this owner holds no helper and starts none. + let settled = abandoned(&owner, &admission, Abandon::GrantNotReceived); + assert_eq!( + (settled.phase, settled.release, settled.known_not_started), + (Phase::Released, Some(Release::NoHelperCreated), true) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn a_helper_that_cannot_present_the_grant_never_runs_the_executable() { + let Some(helper) = helper_bin() else { return }; + let provisioned = admitting(); + let owner = provisioned.owner(); + let marker = provisioned_marker(&provisioned); + let argv = ["/usr/bin/touch", marker.to_str().unwrap()]; + let admission = admission("cs-launch-3", &argv); + admitted(&owner, &admission); + let permit = granted(&owner, &admission); + // A ticket that names an endpoint no authority serves: the helper fails at + // `connect`, before it could claim the grant. + let ticket = LaunchTicket { + endpoint: provisioned.settings.socket.with_file_name("absent.sock"), + consumer: owner.consumer().into(), + generation: owner.generation().into(), + attempt_id: admission.attempt_id.clone(), + instance_id: owner.instance_id().into(), + }; + let invocation = HelperInvocation::new( + &helper, + &ticket, + permit, + Path::new(argv[0]), + &[argv[1].into()], + ) + .unwrap(); + let mut child = spawn(&invocation, Stdio::null()); + assert_eq!( + invocation.into_transcript().read(), + TranscriptEnd::Failed { + stage: HelperStage::Connect + } + ); + let status = child.wait().unwrap(); + assert_eq!( + status.code(), + Some(devguard_client::launch::NOT_AUTHORIZED_STATUS) + ); + assert!(!marker.exists(), "the executable ran"); + // The helper was reaped before READY and claimed nothing: the owner's report + // releases the grant as never started. + let settled = abandoned(&owner, &admission, Abandon::HelperExited); + assert_eq!( + (settled.phase, settled.release, settled.known_not_started), + (Phase::Released, Some(Release::NoHelperCreated), true) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn an_exec_failure_after_ready_is_settled_by_its_scope() { + let Some(helper) = helper_bin() else { return }; + let provisioned = admitting(); + let owner = provisioned.owner(); + // A program that exists but cannot be executed: the exec after READY fails. + let program = provisioned_marker(&provisioned).with_file_name("not-executable"); + std::fs::write(&program, b"#!/bin/sh\n").unwrap(); + let admission = admission("cs-launch-4", &[program.to_str().unwrap()]); + admitted(&owner, &admission); + let permit = granted(&owner, &admission); + let invocation = owner + .helper(&helper, &admission, permit, &program, &[]) + .unwrap(); + let mut child = spawn(&invocation, Stdio::null()); + assert_eq!( + invocation.into_transcript().read(), + TranscriptEnd::ExecFailed { + errno: libc::EACCES + } + ); + assert_eq!( + child.wait().unwrap().code(), + Some(devguard_client::launch::EXEC_FAILED_STATUS) + ); + // The helper claimed the grant before READY, so an owner report cannot release it; + // only the observed end of its scope does. + let reported = abandoned(&owner, &admission, Abandon::HelperExited); + assert_ne!(reported.release, Some(Release::NoHelperCreated)); + let ended = released(&owner, &admission); + assert_eq!( + (ended.phase, ended.release, ended.known_not_started), + (Phase::Released, Some(Release::ScopeTerminated), false) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn the_executable_keeps_none_of_the_helpers_descriptors() { + let Some(helper) = helper_bin() else { return }; + let provisioned = admitting(); + let owner = provisioned.owner(); + // The helper's descriptor numbers are known only once its invocation exists, after + // the command was admitted, so the probe reports every open number up to a bound + // above them. + let script = "n=3; while [ $n -lt 1024 ]; do \ + if [ -e /dev/fd/$n ]; then echo $n; fi; n=$((n + 1)); done; echo end"; + let argv = ["/bin/sh", "-c", script]; + let admission = admission("cs-launch-5", &argv); + admitted(&owner, &admission); + let permit = granted(&owner, &admission); + let invocation = owner + .helper( + &helper, + &admission, + permit, + Path::new(argv[0]), + &[argv[1].into(), argv[2].into()], + ) + .unwrap(); + assert!(invocation.descriptors().iter().all(|fd| *fd < 1024)); + let child = spawn(&invocation, Stdio::piped()); + assert_eq!(invocation.into_transcript().read(), TranscriptEnd::Started); + let output = child.wait_with_output().unwrap(); + // Neither the permit carrier, nor the transcript writer, nor the helper's session + // socket reaches the executable. + assert_eq!(String::from_utf8(output.stdout).unwrap(), "end\n"); + assert_eq!( + released(&owner, &admission).release, + Some(Release::ScopeTerminated) + ); + } + + /// A path in the provisioned authority's private directory. + fn provisioned_marker(provisioned: &Provisioned) -> PathBuf { + let base = provisioned.settings.socket.parent().unwrap().to_path_buf(); + base.join(format!("started-{}", std::process::id())) + } + + #[test] + fn the_default_request_fits_the_fixture() { + // The launch tests ask what the admission tests ask. + assert_eq!(request().requested.cpu_milli, 100); + } + } +} diff --git a/crates/devguard/src/lib.rs b/crates/devguard/src/lib.rs index ccf3696..97b9846 100644 --- a/crates/devguard/src/lib.rs +++ b/crates/devguard/src/lib.rs @@ -5,8 +5,10 @@ //! and closes it (CSRG-U1). [`Owner::register`] registers the process that owns CodeSpace's //! executions in such a session (CSRG-U2), and [`handoff`] hands the consumer secret to the UDS //! worker that owns them. [`admission`] admits, looks up and cancels one attempt of that owner -//! (CSRG-U3). Nothing here launches anything. Every outcome is a [`Status`], a [`Registration`] -//! or an [`admission::Answer`], never an error for the caller. +//! (CSRG-U3). [`launch`] commits an admitted attempt's launch once and builds the invocation of +//! DevGuard's launch helper, whose process the owner starts itself (CSRG-U4); nothing here +//! spawns anything. Every outcome is a [`Status`], a [`Registration`], an +//! [`admission::Answer`] or a [`launch::LaunchAnswer`], never an error for the caller. //! //! DevGuard's transport bounds the session: connecting and each frame read or write have a //! 250 ms deadline, so a probe ends within about 1.75 s and a registration within about 2.25 s. @@ -24,6 +26,7 @@ pub mod admission; pub mod handoff; +pub mod launch; mod registration; pub use registration::{Owner, OwnerCredential, OwnerSettings, Registration, RegistrationState}; diff --git a/crates/pty/Cargo.lock b/crates/pty/Cargo.lock index 2aefad0..d4824bc 100644 --- a/crates/pty/Cargo.lock +++ b/crates/pty/Cargo.lock @@ -43,6 +43,7 @@ name = "codespace-pty" version = "0.6.0" dependencies = [ "codex-utils-pty", + "libc", "tempfile", "tokio", ] diff --git a/crates/pty/Cargo.toml b/crates/pty/Cargo.toml index e9eb37b..bc5edd2 100644 --- a/crates/pty/Cargo.toml +++ b/crates/pty/Cargo.toml @@ -19,5 +19,6 @@ codex-utils-pty = { path = "../../third_party/codex/codex-rs/utils/pty" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "process", "time"] } [dev-dependencies] +libc = "0.2" tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "process", "time"] } diff --git a/crates/pty/src/lib.rs b/crates/pty/src/lib.rs index 020cdea..c831efe 100644 --- a/crates/pty/src/lib.rs +++ b/crates/pty/src/lib.rs @@ -2,6 +2,7 @@ //! CodeSpace-owned types. Not an MCP tool. use std::collections::HashMap; +use std::os::fd::RawFd; use std::path::Path; use tokio::sync::{mpsc, oneshot}; @@ -59,6 +60,27 @@ pub async fn spawn( args: &[String], cwd: &Path, env: &HashMap, +) -> Result { + spawn_inheriting(program, args, cwd, env, &[]).await +} + +/// [`spawn`], keeping the descriptors `inherited` open in the child at their numbers (CSRG-U4). +/// +/// The pinned Codex PTY passes on only descriptors that are already inheritable: its child +/// closes every other inheritable descriptor above the standard three before exec, and those +/// that are close-on-exec close at exec. So each of `inherited` must be inheritable in this +/// process when this is called, and the caller keeps that window as short as the spawn. A child +/// another thread spawns meanwhile would hold them unless its spawner excludes unrelated +/// descriptors, as CodeSpace's spawners do. With `inherited` empty this is [`spawn`]. The +/// session is the same either way: the same terminal size, resize, session leader with its +/// controlling terminal, process-group termination and exit report, except that this path +/// reports a signal death as 128 plus the signal number instead of 1. +pub async fn spawn_inheriting( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + inherited: &[RawFd], ) -> Result { if program.is_empty() { return Err("command must be a non-empty argv (no shell)".into()); @@ -73,7 +95,7 @@ pub async fn spawn( rows: DEFAULT_ROWS, cols: DEFAULT_COLS, }, - &[], + inherited, ) .await .map_err(|err| err.to_string())?; @@ -159,6 +181,142 @@ mod tests { panic!("timed out waiting for {needle:?}, got {text:?}"); } + /// An inheritable copy of /dev/null at or above `at`, closed when dropped. + fn inheritable_at(at: RawFd) -> std::os::fd::OwnedFd { + use std::os::fd::{AsRawFd, FromRawFd}; + let null = std::fs::File::open("/dev/null").unwrap(); + // SAFETY: F_DUPFD returns a new descriptor without close-on-exec, owned below. + let fd = unsafe { libc::fcntl(null.as_raw_fd(), libc::F_DUPFD, at) }; + assert!(fd >= at); + // SAFETY: as above. + unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) } + } + + async fn output_of(mut session: PtySession) -> (i32, String) { + let mut output = session.take_stdout().expect("stdout"); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + let text = tokio::time::timeout(Duration::from_secs(5), async { + let mut bytes = Vec::new(); + while let Some(chunk) = output.recv().await { + bytes.extend(chunk); + } + String::from_utf8(bytes).unwrap().replace("\r\n", "\n") + }) + .await + .expect("output timeout"); + (code, text) + } + + fn shell_env(dir: &Path) -> HashMap { + HashMap::from([ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), dir.display().to_string()), + ("LANG".into(), "C".into()), + ("TERM".into(), "xterm".into()), + ]) + } + + #[tokio::test] + async fn only_the_inherited_descriptors_reach_the_child() { + use std::os::fd::AsRawFd; + let dir = tempfile::tempdir().unwrap(); + // Both inheritable here: one is named to keep, the other is what a creation window in + // another thread leaves behind. + let kept = inheritable_at(200); + let unrelated = inheritable_at(300); + let script = format!( + "for n in {} {}; do if [ -e /dev/fd/$n ]; then echo held; else echo clear; fi; done", + kept.as_raw_fd(), + unrelated.as_raw_fd() + ); + let session = spawn_inheriting( + "/bin/sh", + &["-c".into(), script], + dir.path(), + &shell_env(dir.path()), + &[kept.as_raw_fd()], + ) + .await + .expect("spawn"); + let (code, text) = output_of(session).await; + assert_eq!((code, text.as_str()), (0, "held\nclear\n")); + // The ordinary path keeps neither. + let script = format!( + "if [ -e /dev/fd/{} ]; then echo held; else echo clear; fi", + kept.as_raw_fd() + ); + let session = spawn( + "/bin/sh", + &["-c".into(), script], + dir.path(), + &shell_env(dir.path()), + ) + .await + .expect("spawn"); + assert_eq!(output_of(session).await, (0, "clear\n".into())); + } + + #[tokio::test] + async fn the_inheriting_path_keeps_the_terminal_semantics() { + let dir = tempfile::tempdir().unwrap(); + let kept = inheritable_at(200); + let script = "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; \ + if [ -t 0 ] && [ -t 1 ]; then echo tty; fi; \ + ps -o pid=,pgid=,sess= -p $$ | awk '{ print ($1 == $2 ? \"leader\" : \"member\") }'; \ + IFS= read _line; printf 'after:%s\\n' \"$(stty size)\""; + let mut session = spawn_inheriting( + "/bin/sh", + &["-c".into(), script.into()], + dir.path(), + &shell_env(dir.path()), + &[std::os::fd::AsRawFd::as_raw_fd(&kept)], + ) + .await + .expect("spawn"); + let mut output = session.take_stdout().expect("stdout"); + let writer = session.writer(); + let start = collect_until(&mut output, "leader") + .await + .replace("\r\n", "\n"); + assert!(start.contains("start:24 80\ntty\nleader\n"), "{start:?}"); + session.resize(40, 120).expect("resize"); + writer.send(b"go\n".to_vec()).await.expect("write"); + let rest = collect_until(&mut output, "after:40 120").await; + assert!( + rest.replace("\r\n", "\n").contains("after:40 120"), + "{rest:?}" + ); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + assert_eq!(code, 0); + } + + #[tokio::test] + async fn the_two_paths_report_a_signal_death_differently() { + let dir = tempfile::tempdir().unwrap(); + let kept = inheritable_at(200); + let argv = ["-c".to_owned(), "kill -KILL $$".to_owned()]; + let inheriting = spawn_inheriting( + "/bin/sh", + &argv, + dir.path(), + &shell_env(dir.path()), + &[std::os::fd::AsRawFd::as_raw_fd(&kept)], + ) + .await + .expect("spawn"); + assert_eq!(output_of(inheriting).await.0, 128 + libc::SIGKILL); + let portable = spawn("/bin/sh", &argv, dir.path(), &shell_env(dir.path())) + .await + .expect("spawn"); + assert_eq!(output_of(portable).await.0, 1); + } + #[tokio::test] async fn resize_changes_stty_size() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index 0ca9a55..04ea1bc 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -14,9 +14,10 @@ //! termination and reaping; DevGuard keeps admission, reservations and the attempt's durable //! identity and accounting. //! -//! Managed launch (`BeginLaunch`, its permit and carrier, the helper) is U4's. Until it exists -//! [`PreparedExecution::launch`] cannot launch: it cancels the unstarted attempt and answers -//! [`PreparationOutcome::LaunchUnavailable`]. Nothing is ever run outside the authority instead. +//! Managed launch (`BeginLaunch`, its permit and carrier, the helper) is in [`crate::launch`] +//! (CSRG-U4). An owner without a launch helper cannot launch: [`PreparedExecution::launch`] +//! then cancels the unstarted attempt and answers [`PreparationOutcome::LaunchUnavailable`]. +//! Nothing is ever run outside the authority instead. //! //! Uncertainty stays conservative. A timeout, a lost reply or an EOF is not taken as a refusal //! or as proof that no attempt exists: the attempt is looked up once, cancelled when the lookup @@ -29,29 +30,47 @@ //! never asked DevGuard to launch the attempt, so a cancellation finds it prepared or settled. use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; use codespace_devguard as devguard; -use codespace_devguard::admission::{self as dg, Admission, Answer, Meaning}; +use codespace_devguard::admission::{self as dg, Abandon, Admission, Answer, Meaning}; +use codespace_devguard::launch::{HelperInvocation, LaunchAnswer, Permit}; use codespace_domain::{ ErrorBody, ErrorCode, ProcessId, ResourceAuthorityErrorCode, ResourceRegistrationState, }; use codespace_policy::{EnforcementLevel, Participation, ResourceRequest, Workspace}; use serde::{Deserialize, Serialize}; +use crate::launch::{LaunchReport, Launcher}; use crate::process::{spawn_env, SlotReservation}; use crate::registration::{error_code, registration_state}; use crate::{InProcessRunner, RunnerCwd, RunnerExecRequest}; -/// The authority that admits, finds and cancels an owner's attempts: DevGuard's, through the -/// registered owner. Each call is one bounded, blocking session. +/// The authority that admits, finds, cancels and launches an owner's attempts: DevGuard's, +/// through the registered owner. Each call is one bounded, blocking session, except +/// [`Self::helper`], which only builds the helper's invocation. pub trait AttemptAuthority: Send + Sync + 'static { fn consumer(&self) -> &str; fn generation(&self) -> &str; fn admit(&self, admission: &Admission) -> Answer; fn lookup(&self, admission: &Admission) -> Answer; fn cancel(&self, admission: &Admission) -> Answer; + /// Commit the attempt's launch (CSRG-U4). Sent at most once per attempt. + fn begin_launch(&self, admission: &Admission) -> LaunchAnswer; + /// Report that this owner holds no helper for the attempt's grant (CSRG-U4). + fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer; + /// The launch helper's invocation for the attempt's grant (CSRG-U4). + fn helper( + &self, + helper: &Path, + admission: &Admission, + permit: Permit, + program: &Path, + args: &[OsString], + ) -> Result; } impl AttemptAuthority for devguard::Owner { @@ -70,6 +89,22 @@ impl AttemptAuthority for devguard::Owner { fn cancel(&self, admission: &Admission) -> Answer { devguard::Owner::cancel(self, admission) } + fn begin_launch(&self, admission: &Admission) -> LaunchAnswer { + devguard::Owner::begin_launch(self, admission) + } + fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer { + devguard::Owner::abandon_launch(self, admission, reason) + } + fn helper( + &self, + helper: &Path, + admission: &Admission, + permit: Permit, + program: &Path, + args: &[OsString], + ) -> Result { + devguard::Owner::helper(self, helper, admission, permit, program, args) + } } /// The identity of one attempt, which both CodeSpace and DevGuard hold: the consumer and @@ -144,8 +179,38 @@ pub struct Reserved { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Applied { - /// Nothing was launched, so nothing was applied. Always so before managed launch (U4). + /// Nothing was launched, so nothing was applied. NotLaunched, + /// Launched, and DevGuard's record shows, per resource, what it applied to the scope + /// before it authorized the run (CSRG-U4). + Launched { + cpu: AppliedControl, + memory: AppliedControl, + pids: AppliedControl, + }, + /// Launched, but the record could not be read, or the launch was not confirmed. + Unknown, +} + +/// What DevGuard did with one resource's control in a launched scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppliedControl { + Planned, + Applied, + Unsupported, + Failed, +} + +impl From for AppliedControl { + fn from(application: dg::Application) -> Self { + match application { + dg::Application::Planned => Self::Planned, + dg::Application::Applied => Self::Applied, + dg::Application::Unsupported => Self::Unsupported, + dg::Application::Failed => Self::Failed, + } + } } /// The four stages of an admission, kept apart: what was requested and the weakest control @@ -175,10 +240,19 @@ pub enum AttemptState { Cancelled, /// Expired unlaunched: its reservation is returned. Expired, + /// Released after this owner reported it holds no helper for the unclaimed launch grant: + /// the authority's proof that the command never started (CSRG-U4). + Released, + /// A launch helper claimed it, so the authority holds its reservation until it observes the + /// scope end, and then releases it (CSRG-U4). + Launched, + /// Released after the authority observed its scope end (CSRG-U4). + ScopeEnded, /// The authority holds it in another phase. Other, - /// Not known: it may be prepared, holding its reservation until the authority expires it. - /// Nothing was started for it. + /// Not known: it may be prepared, holding its reservation until the authority expires it, + /// or committed for launch, holding it until the authority settles it. Nothing was started + /// for it by this owner. Unknown, } @@ -214,6 +288,59 @@ pub enum PreparationOutcome { DigestMismatch, /// Whether the authority admitted it is not known. Unknown, + /// The owner's launch helper cannot be used (CSRG-U4); the unstarted attempt was cancelled. + HelperUnusable { problem: HelperProblem }, + /// The command's program is not an executable file on its `PATH` (CSRG-U4); the unstarted + /// attempt was cancelled. + ProgramUnavailable, + /// Enabled network needs the Linux command sandbox, which a managed launch does not use + /// (CSRG-U4); the unstarted attempt was cancelled. + NetworkUnsupported, + /// The Linux command sandbox would wrap the command, which a managed launch does not support + /// (CSRG-U4); the unstarted attempt was cancelled. + SandboxUnsupported, + /// The authority refused `BeginLaunch`, so nothing was committed (CSRG-U4). + LaunchRefused { + error_code: ResourceAuthorityErrorCode, + }, + /// No usable answer to `BeginLaunch` came back; it was not sent again (CSRG-U4). The + /// attempt state says what one lookup found and how it was settled. + LaunchUnknown, + /// The launch was committed, but the helper could not be created (CSRG-U4). + HelperNotCreated, + /// The authority did not authorize the helper, which never attempted the command (CSRG-U4). + HelperRefused { + error_code: ResourceAuthorityErrorCode, + }, + /// The helper failed before presenting the grant and never attempted the command (CSRG-U4). + HelperFailed { stage: HelperStage }, + /// The helper ended before READY without a report, so it never attempted the command + /// (CSRG-U4). + HelperEnded, + /// The helper was authorized, and its exec of the command failed with this errno (CSRG-U4). + ExecFailed { errno: i32 }, +} + +/// Why the owner's launch helper cannot be used. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HelperProblem { + NotAbsolute, + NotAFile, + NotExecutable, + NotPrivate, +} + +/// Where a launch helper failed before presenting the grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HelperStage { + Scope, + Clamp, + Report, + Permit, + Connect, + Other, } /// The owner's report of one preparation. Nothing was started for it. @@ -228,11 +355,17 @@ pub struct ManagedPreparation { } impl ManagedPreparation { - /// The tool error for this preparation. Every preparation ends in one, as none starts - /// anything (CSRG-U3). Only enumerations and identifiers are stated. + /// The tool error for a preparation that started nothing, or a launch whose command did not + /// start (CSRG-U3, U4). Only enumerations, numbers and identifiers are stated. pub fn into_error_body(self, workspace_id: &str) -> ErrorBody { + // Once a helper process existed, something was started; the command was not. + let started = if self.outcome.created_a_helper() { + "the command was not started" + } else { + "nothing was started" + }; let attempt = format!( - "attempt `{}` for process_id `{}`: {}; nothing was started", + "attempt `{}` for process_id `{}`: {}; {started}", self.attempt_id, self.process_id.0, wire_name(&self.attempt) @@ -298,6 +431,82 @@ impl ManagedPreparation { execution is not known" .to_string(), ), + PreparationOutcome::HelperUnusable { problem } => ( + ErrorCode::ManagedLaunchUnavailable, + format!( + "the resource authority admitted it, but the execution owner's launch helper \ + cannot be used ({}), so the unstarted attempt was cancelled", + wire_name(&problem) + ), + ), + PreparationOutcome::ProgramUnavailable => ( + ErrorCode::ProcessSpawnFailed, + "the command's program is not an executable file on its PATH, so the unstarted \ + attempt was cancelled" + .to_string(), + ), + PreparationOutcome::NetworkUnsupported => ( + ErrorCode::ProcessSpawnFailed, + "Enabled network requires the Linux command sandbox helper, which a managed \ + launch does not use, so the unstarted attempt was cancelled" + .to_string(), + ), + PreparationOutcome::SandboxUnsupported => ( + ErrorCode::ResourcePolicyUnsupported, + "a managed launch cannot run inside the Linux command sandbox, so the unstarted \ + attempt was cancelled" + .to_string(), + ), + PreparationOutcome::LaunchRefused { error_code } => ( + crate::launch::launch_refusal(error_code), + format!( + "the resource authority refused to launch it ({}), so nothing was committed", + wire_name(&error_code) + ), + ), + PreparationOutcome::LaunchUnknown => ( + if self.attempt == AttemptState::Unknown { + ErrorCode::AdmissionUnknown + } else { + ErrorCode::ResourceAuthorityUnavailable + }, + "no answer came back from the resource authority when the launch was committed, \ + and it was not asked again" + .to_string(), + ), + PreparationOutcome::HelperNotCreated => ( + ErrorCode::ProcessSpawnFailed, + "the launch was committed, but its helper could not be started".to_string(), + ), + PreparationOutcome::HelperRefused { error_code } => ( + crate::launch::launch_refusal(error_code), + format!( + "the resource authority did not authorize the launch helper ({}), which \ + never attempted the command", + wire_name(&error_code) + ), + ), + PreparationOutcome::HelperFailed { stage } => ( + ErrorCode::ResourceAuthorityUnavailable, + format!( + "the launch helper failed before it presented the launch ({}) and never \ + attempted the command", + wire_name(&stage) + ), + ), + PreparationOutcome::HelperEnded => ( + ErrorCode::ProcessSpawnFailed, + "the launch helper ended before it reported ready, so it never attempted the \ + command" + .to_string(), + ), + PreparationOutcome::ExecFailed { errno } => ( + ErrorCode::ProcessSpawnFailed, + format!( + "the resource authority authorized the launch, but the command could not be \ + executed (errno {errno})" + ), + ), }; ErrorBody::new( code, @@ -308,6 +517,19 @@ impl ManagedPreparation { } } +impl PreparationOutcome { + /// Whether a launch helper process existed for this outcome. + pub fn created_a_helper(&self) -> bool { + matches!( + self, + Self::HelperRefused { .. } + | Self::HelperFailed { .. } + | Self::HelperEnded + | Self::ExecFailed { .. } + ) + } +} + fn wire_name(value: &impl Serialize) -> String { serde_json::to_value(value) .ok() @@ -315,13 +537,14 @@ fn wire_name(value: &impl Serialize) -> String { .unwrap_or_default() } -/// Prepares governed executions for the owner that holds both the process slots and the -/// registered identity. +/// Prepares and launches governed executions for the owner that holds both the process slots +/// and the registered identity. #[derive(Clone)] pub struct Preparer { authority: Arc, runner: InProcessRunner, ttl_bound: Duration, + launcher: Option, } impl Preparer { @@ -330,6 +553,7 @@ impl Preparer { authority, runner, ttl_bound: Duration::from_millis(devguard_prepared_ttl()), + launcher: None, } } @@ -339,6 +563,13 @@ impl Preparer { self } + /// Launch prepared executions through `launcher` (CSRG-U4). Without one, a prepared + /// execution is cancelled and refused as `launch_unavailable`. + pub fn with_launcher(mut self, launcher: Option) -> Self { + self.launcher = launcher; + self + } + /// Prepare the execution `req` in `ws` as attempt `attempt_id`, or report why not. Only a /// workspace that requires resource participation is prepared. pub async fn prepare( @@ -424,6 +655,7 @@ impl Preparer { deadline: started + ttl, admission, authority: self.authority.clone(), + runner: self.runner.clone(), slot: Some(slot), settled: false, }) @@ -468,9 +700,41 @@ impl Preparer { } } - /// U3's whole path for a governed execution: prepare it, then present it for launch, which - /// cannot launch yet and cancels it. It runs as its own task, so a caller that stops - /// waiting leaves the preparation to settle rather than abandoning a prepared attempt. + /// The whole path of a governed execution (CSRG-U4): prepare it, then launch it once + /// through this preparer's launcher. It runs as its own task, so a caller that stops + /// waiting leaves the launch to finish and settle rather than abandoning it halfway. + pub async fn prepare_and_launch( + &self, + ws: Workspace, + req: RunnerExecRequest, + attempt_id: String, + ) -> LaunchReport { + let preparer = self.clone(); + let (attempt, process_id) = (attempt_id.clone(), req.process_id.clone()); + let task = tokio::spawn(async move { + let launch = req.clone(); + match preparer.prepare(&ws, req, attempt_id).await { + Ok(prepared) => { + prepared + .launch_managed(&launch, preparer.launcher.as_ref()) + .await + } + Err(report) => LaunchReport::NotLaunched(*report), + } + }); + task.await + .unwrap_or(LaunchReport::NotLaunched(ManagedPreparation { + attempt_id: attempt, + process_id, + outcome: PreparationOutcome::Unknown, + attempt: AttemptState::Unknown, + account: None, + })) + } + + /// U3's path for a governed execution: prepare it, then present it for launch without a + /// launcher, which cancels it. It runs as its own task, so a caller that stops waiting + /// leaves the preparation to settle rather than abandoning a prepared attempt. pub async fn prepare_and_dispose( &self, ws: Workspace, @@ -505,15 +769,17 @@ impl Preparer { /// Dropping it unsettled cancels the attempt in the background; a local task ending is not /// taken as the attempt's release. pub struct PreparedExecution { - identity: AttemptIdentity, - ws: Workspace, - request: RunnerExecRequest, - account: AdmissionAccount, - deadline: Instant, - admission: Admission, - authority: Arc, - slot: Option, - settled: bool, + pub(crate) identity: AttemptIdentity, + pub(crate) ws: Workspace, + pub(crate) request: RunnerExecRequest, + pub(crate) account: AdmissionAccount, + pub(crate) deadline: Instant, + pub(crate) admission: Admission, + pub(crate) authority: Arc, + /// The runner whose slot it holds, which spawns its launch helper. + pub(crate) runner: InProcessRunner, + pub(crate) slot: Option, + pub(crate) settled: bool, } impl std::fmt::Debug for PreparedExecution { @@ -543,22 +809,17 @@ impl PreparedExecution { &self.request } - /// Present `request` for launch. Only the prepared command, before its deadline, could be - /// launched; managed launch is not available yet (U4), so the unstarted attempt is - /// cancelled in every case and nothing is started. - pub async fn launch(mut self, request: &RunnerExecRequest) -> ManagedPreparation { - let outcome = if meaning(&self.ws, request).digest().as_deref() - != Some(self.identity.digest.as_str()) - || request.process_id != self.identity.process_id - { - PreparationOutcome::DigestMismatch - } else if Instant::now() >= self.deadline { - PreparationOutcome::Expired - } else { - PreparationOutcome::LaunchUnavailable - }; - let attempt = self.settle().await; - self.report(outcome, attempt) + /// Present `request` for launch without a launch helper: only the prepared command, before + /// its deadline, could be launched, and without a helper this owner cannot launch it, so the + /// unstarted attempt is cancelled in every case and nothing is started. With a helper, see + /// [`Self::launch_managed`]. + pub async fn launch(self, request: &RunnerExecRequest) -> ManagedPreparation { + match self.launch_managed(request, None).await { + LaunchReport::NotLaunched(report) => report, + LaunchReport::Launched(launch) => { + unreachable!("launched without a launcher: {launch:?}") + } + } } /// Cancel the unstarted attempt and free its slot. @@ -566,14 +827,18 @@ impl PreparedExecution { self.settle().await } - async fn settle(&mut self) -> AttemptState { + pub(crate) async fn settle(&mut self) -> AttemptState { self.settled = true; let attempt = cancelled(call(&self.authority, &self.admission, Call::Cancel).await); self.slot = None; attempt } - fn report(&self, outcome: PreparationOutcome, attempt: AttemptState) -> ManagedPreparation { + pub(crate) fn report( + &self, + outcome: PreparationOutcome, + attempt: AttemptState, + ) -> ManagedPreparation { ManagedPreparation { attempt_id: self.identity.attempt_id.clone(), process_id: self.identity.process_id.clone(), @@ -599,19 +864,25 @@ impl Drop for PreparedExecution { } #[derive(Debug, Clone, Copy)] -enum Call { +pub(crate) enum Call { Admit, Lookup, Cancel, + Abandon(Abandon), } /// One blocking session on its own thread. A thread that cannot finish is unknown. -async fn call(authority: &Arc, admission: &Admission, call: Call) -> Answer { +pub(crate) async fn call( + authority: &Arc, + admission: &Admission, + call: Call, +) -> Answer { let (authority, admission) = (authority.clone(), admission.clone()); tokio::task::spawn_blocking(move || match call { Call::Admit => authority.admit(&admission), Call::Lookup => authority.lookup(&admission), Call::Cancel => authority.cancel(&admission), + Call::Abandon(reason) => authority.abandon_launch(&admission, reason), }) .await .unwrap_or(Answer::Unknown) @@ -629,13 +900,38 @@ async fn resolve(authority: &Arc, admission: &Admission) - } } -fn cancelled(answer: Answer) -> AttemptState { +pub(crate) fn cancelled(answer: Answer) -> AttemptState { match answer { Answer::Attempt(attempt) => state(attempt.phase), Answer::Refused(_) | Answer::NotSent(_) | Answer::Unknown => AttemptState::Unknown, } } +/// What an `AbandonLaunch` answer shows (CSRG-U4). +pub(crate) fn abandoned(answer: Answer) -> AttemptState { + match answer { + Answer::Attempt(attempt) => attempt_state(&attempt), + Answer::Refused(_) | Answer::NotSent(_) | Answer::Unknown => AttemptState::Unknown, + } +} + +/// What a record of a possibly launched attempt shows (CSRG-U4). +pub(crate) fn attempt_state(attempt: &dg::Attempt) -> AttemptState { + match (attempt.phase, attempt.release) { + (dg::Phase::Released, Some(dg::Release::NoHelperCreated)) => AttemptState::Released, + (dg::Phase::Released, _) => AttemptState::ScopeEnded, + ( + dg::Phase::LaunchCommitted + | dg::Phase::ScopeBound + | dg::Phase::RunAuthorized + | dg::Phase::Draining + | dg::Phase::Suspect, + _, + ) => AttemptState::Launched, + (phase, _) => state(phase), + } +} + fn state(phase: dg::Phase) -> AttemptState { match phase { dg::Phase::Denied => AttemptState::Denied, @@ -654,10 +950,14 @@ pub(crate) fn meaning(ws: &Workspace, req: &RunnerExecRequest) -> Meaning { let cwd = match req.cwd { RunnerCwd::WorkspaceRoot => ws.root.clone(), }; - let environment: BTreeMap = + let mut environment: BTreeMap = spawn_env(&cwd, req, crate::linux_sandbox_available()) .into_iter() .collect(); + // The PTY spawner gives its child a terminal type too. + if req.tty && req.env.use_runner_defaults { + environment.insert("TERM".into(), "xterm".into()); + } Meaning { executable: req.argv.first().cloned().unwrap_or_default(), cwd: format!( @@ -762,43 +1062,64 @@ fn reserved(reservation: dg::Reservation) -> Reserved { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use codespace_devguard::launch::{HelperInvocation, LaunchTicket}; use codespace_domain::{Profile, WorkspaceId}; use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; + /// What the fake answers to `BeginLaunch` (CSRG-U4). + #[derive(Debug, Clone, Copy)] + pub(crate) enum Grant { + Granted, + Refused(devguard::ErrorCode), + NotSent, + Unknown, + } + /// An authority whose answers the test sets, counting each call. #[derive(Default)] - struct Fake { + pub(crate) struct Fake { admits: Mutex>, lookups: Mutex>, cancels: Mutex>, + grants: Mutex>, + abandons: Mutex>, admit_delay: Mutex, - calls: [AtomicUsize; 3], + calls: [AtomicUsize; 5], seen: Mutex>, + pub(crate) abandoned_for: Mutex>, } impl Fake { - fn admitting(answers: impl IntoIterator) -> Arc { + pub(crate) fn admitting(answers: impl IntoIterator) -> Arc { let fake = Self::default(); fake.admits.lock().unwrap().extend(answers); Arc::new(fake) } - fn then_lookup(self: Arc, answer: Answer) -> Arc { + pub(crate) fn then_lookup(self: Arc, answer: Answer) -> Arc { self.lookups.lock().unwrap().push_back(answer); self } - fn then_cancel(self: Arc, answer: Answer) -> Arc { + pub(crate) fn then_cancel(self: Arc, answer: Answer) -> Arc { self.cancels.lock().unwrap().push_back(answer); self } + pub(crate) fn then_grant(self: Arc, grant: Grant) -> Arc { + self.grants.lock().unwrap().push_back(grant); + self + } + pub(crate) fn then_abandon(self: Arc, answer: Answer) -> Arc { + self.abandons.lock().unwrap().push_back(answer); + self + } fn slow(self: Arc, delay: Duration) -> Arc { *self.admit_delay.lock().unwrap() = delay; self } - fn count(&self, call: usize) -> usize { + pub(crate) fn count(&self, call: usize) -> usize { self.calls[call].load(Ordering::SeqCst) } fn next(queue: &Mutex>, default: Answer) -> Answer { @@ -806,9 +1127,15 @@ mod tests { } } - const ADMIT: usize = 0; - const LOOKUP: usize = 1; - const CANCEL: usize = 2; + pub(crate) const ADMIT: usize = 0; + pub(crate) const LOOKUP: usize = 1; + pub(crate) const CANCEL: usize = 2; + pub(crate) const BEGIN: usize = 3; + pub(crate) const ABANDON: usize = 4; + + /// A permit as a test authority would grant one. + pub(crate) const PERMIT: &str = + "7e577e577e577e577e577e577e577e577e577e577e577e577e577e577e577e57"; impl AttemptAuthority for Fake { fn consumer(&self) -> &str { @@ -833,9 +1160,86 @@ mod tests { self.seen.lock().unwrap().push(admission.clone()); Self::next(&self.cancels, attempt(dg::Phase::Cancelled, None)) } + fn begin_launch(&self, admission: &Admission) -> LaunchAnswer { + self.calls[BEGIN].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + let grant = self + .grants + .lock() + .unwrap() + .pop_front() + .unwrap_or(Grant::Granted); + match grant { + Grant::Granted => LaunchAnswer::Granted { + attempt: record(dg::Phase::LaunchCommitted, None), + permit: Permit::from_text(PERMIT).unwrap(), + }, + Grant::Refused(code) => LaunchAnswer::Refused(code), + Grant::NotSent => LaunchAnswer::NotSent(unreachable_registration()), + Grant::Unknown => LaunchAnswer::Unknown, + } + } + fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer { + self.calls[ABANDON].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + self.abandoned_for.lock().unwrap().push(reason); + Self::next( + &self.abandons, + Answer::Attempt(record( + dg::Phase::Released, + Some(dg::Release::NoHelperCreated), + )), + ) + } + fn helper( + &self, + helper: &Path, + admission: &Admission, + permit: Permit, + program: &Path, + args: &[OsString], + ) -> Result { + let ticket = LaunchTicket { + endpoint: "/private/tmp/codespace-fake-authority.sock".into(), + consumer: "codespace".into(), + generation: "g1".into(), + attempt_id: admission.attempt_id.clone(), + instance_id: "codespace-fake".into(), + }; + HelperInvocation::new(helper, &ticket, permit, program, args) + } + } + + /// A session that ended before its request: the authority could not be reached. + pub(crate) fn unreachable_registration() -> devguard::Registration { + devguard::Registration { + status: devguard::Status { + state: devguard::State::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + report: None, + }, + state: devguard::RegistrationState::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + pid: None, + } + } + + /// A record of a launched or settled attempt (CSRG-U4). + pub(crate) fn record(phase: dg::Phase, release: Option) -> dg::Attempt { + dg::Attempt { + phase, + denial: None, + plan: None, + reservation: None, + scope: None, + applied: None, + release, + tracking_lost: false, + known_not_started: release == Some(dg::Release::NoHelperCreated), + } } - fn attempt(phase: dg::Phase, denial: Option) -> Answer { + pub(crate) fn attempt(phase: dg::Phase, denial: Option) -> Answer { let prepared = phase == dg::Phase::Prepared; Answer::Attempt(dg::Attempt { phase, @@ -863,16 +1267,19 @@ mod tests { }, prepared_ttl_ms: 5_000, }), - applied: false, + scope: None, + applied: None, + release: None, + tracking_lost: false, known_not_started: !prepared, }) } - fn prepared() -> Answer { + pub(crate) fn prepared() -> Answer { attempt(dg::Phase::Prepared, None) } - fn governed(root: &std::path::Path) -> Workspace { + pub(crate) fn governed(root: &std::path::Path) -> Workspace { let mut ws = Workspace::new( WorkspaceId("gov".into()), root.to_path_buf(), @@ -882,7 +1289,7 @@ mod tests { ws } - fn request(argv: &[&str], process: &str) -> RunnerExecRequest { + pub(crate) fn request(argv: &[&str], process: &str) -> RunnerExecRequest { RunnerExecRequest::for_host( argv.iter().map(|part| part.to_string()).collect(), ProcessId(process.into()), @@ -890,7 +1297,7 @@ mod tests { ) } - fn runner() -> InProcessRunner { + pub(crate) fn runner() -> InProcessRunner { InProcessRunner::new(Arc::new(|_| {})) } diff --git a/crates/runner/src/descriptors.rs b/crates/runner/src/descriptors.rs index a95a373..9570c74 100644 --- a/crates/runner/src/descriptors.rs +++ b/crates/runner/src/descriptors.rs @@ -23,6 +23,10 @@ //! marked) it returns the error and the spawn fails, instead of starting a child that may hold //! an unrelated descriptor. Other platforms always fail this way. //! +//! [`exclude_unrelated_except`] does the same but keeps two named descriptors, which it makes +//! inheritable in the child only: the private descriptors of a DevGuard-managed launch, which +//! reach the launch helper and no other child (CSRG-U4). +//! //! The step runs after fork and before exec. It does not allocate, take locks or panic. It //! calls only `fcntl`, plus `getpid` and `proc_pidinfo` on macOS or `syscall` (`close_range`, //! `openat`, `read`, `close`) on Linux, and it writes only its own stack and `errno`. @@ -55,6 +59,30 @@ pub(crate) fn exclude_unrelated_std( unsafe { command.pre_exec(|| mark_unrelated(Forced::NONE)) } } +/// [`exclude_unrelated`], except that the child keeps `keep` (CSRG-U4): it marks every other +/// descriptor above 2 close-on-exec and clears close-on-exec on each of `keep`, which therefore +/// may stay close-on-exec in this process. A managed launch passes DevGuard's permit carrier and +/// transcript writer this way to its launch helper and to nothing else. If one of `keep` is not +/// open in the child the step fails, and with it the spawn. +#[cfg(any(feature = "devguard", test))] +pub(crate) fn exclude_unrelated_except( + command: &mut tokio::process::Command, + keep: [libc::c_int; 2], +) -> &mut tokio::process::Command { + crate::prepare_fork_spawns(); + // SAFETY: the step runs in the forked child and is restricted as the module documentation + // describes; `keep` is copied into the closure, so it allocates nothing there. + unsafe { + command.pre_exec(move || { + mark_unrelated_except(Forced::NONE, &keep)?; + for fd in keep { + keep_open(fd)?; + } + Ok(()) + }) + } +} + /// Failures the tests force. Production code passes `Forced::NONE`. #[derive(Clone, Copy)] struct Forced { @@ -73,6 +101,12 @@ impl Forced { /// Runs in the child: mark every descriptor above 2 close-on-exec, or fail. fn mark_unrelated(forced: Forced) -> io::Result<()> { + mark_unrelated_except(forced, &[]) +} + +/// [`mark_unrelated`], leaving `keep` as it is. (`close_range` marks them too; the caller then +/// clears them.) +fn mark_unrelated_except(forced: Forced, keep: &[libc::c_int]) -> io::Result<()> { #[cfg(target_os = "linux")] { if !forced.close_range && close_range_cloexec() { @@ -87,7 +121,24 @@ fn mark_unrelated(forced: Forced) -> io::Result<()> { descriptor_table_size() }?; for fd in 3..end { - mark_close_on_exec(fd)?; + if !keep.contains(&fd) { + mark_close_on_exec(fd)?; + } + } + Ok(()) +} + +/// Runs in the child: clear close-on-exec on `fd`, which must be open. +#[cfg(any(feature = "devguard", test))] +fn keep_open(fd: libc::c_int) -> io::Result<()> { + // SAFETY: fcntl reads and sets only this process's descriptor flags. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: as above. + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); } Ok(()) } @@ -337,6 +388,117 @@ pub(crate) mod tests { assert_eq!(report, "held\nclear\n"); } + /// A close-on-exec descriptor at or above `at`, as DevGuard creates a launch's carriers. + fn close_on_exec_at(at: libc::c_int) -> OwnedFd { + let held = inheritable_at(at); + // SAFETY: sets the flags of a descriptor this test owns. + assert_eq!( + unsafe { libc::fcntl(held.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + held + } + + /// A shell command that reports, for each of `fds`, whether it holds it. + fn report_each(fds: &[libc::c_int]) -> String { + let numbers: Vec = fds.iter().map(|fd| fd.to_string()).collect(); + format!( + "for n in {}; do if [ -e /dev/fd/$n ]; then echo held; else echo clear; fi; done", + numbers.join(" ") + ) + } + + async fn tokio_report( + script: String, + prepare: impl FnOnce(&mut tokio::process::Command), + ) -> String { + let mut command = tokio::process::Command::new("/bin/sh"); + command + .arg("-c") + .arg(script) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + prepare(&mut command); + let output = command.output().await.expect("spawn /bin/sh"); + String::from_utf8(output.stdout).expect("utf-8 report") + } + + #[tokio::test] + async fn only_the_kept_descriptors_reach_the_child() { + // Close-on-exec here, as a launch's carriers are; and one inheritable, as another + // thread's creation window would leave. + let permit = close_on_exec_at(210); + let report = close_on_exec_at(220); + let unrelated = inheritable_descriptor(); + let fds = [ + permit.as_raw_fd(), + report.as_raw_fd(), + unrelated.as_raw_fd(), + ]; + // The control: a plain child holds only the inheritable one. + assert_eq!( + tokio_report(report_each(&fds), |_| {}).await, + "clear\nclear\nheld\n" + ); + let kept = tokio_report(report_each(&fds), |command| { + exclude_unrelated_except(command, [fds[0], fds[1]]); + }) + .await; + assert_eq!(kept, "held\nheld\nclear\n"); + // This process's copies stay close-on-exec. + for fd in [fds[0], fds[1]] { + // SAFETY: reads the flags of a descriptor this test owns. + assert_ne!( + unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC, + 0 + ); + } + } + + #[tokio::test] + async fn a_kept_descriptor_that_is_not_open_fails_the_spawn() { + let dir = tempfile::tempdir().unwrap(); + let ran = dir.path().join("ran"); + let gone = close_on_exec_at(230); + let number = gone.as_raw_fd(); + drop(gone); + let mut command = tokio::process::Command::new("/bin/sh"); + command + .arg("-c") + .arg(format!(": > '{}'", ran.display())) + .stdin(Stdio::null()); + exclude_unrelated_except(&mut command, [number, number]); + let error = command.spawn().expect_err("the spawn must fail"); + assert_eq!(error.raw_os_error(), Some(libc::EBADF)); + assert!(!ran.exists(), "the child must not run"); + } + + #[test] + fn the_table_walk_keeps_only_the_kept_descriptors() { + let kept = close_on_exec_at(240); + let unrelated = inheritable_descriptor(); + let fds = [kept.as_raw_fd(), unrelated.as_raw_fd()]; + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg(report_each(&fds)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + crate::prepare_fork_spawns(); + let keep = [fds[0], fds[0]]; + // SAFETY: as for `exclude_unrelated_except`. + unsafe { + command.pre_exec(move || { + mark_unrelated_except(WALK, &keep)?; + keep_open(keep[0]) + }); + } + let output = command.output().unwrap(); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "held\nclear\n"); + } + #[test] fn unavailable_table_size_fails_the_spawn() { let held = inheritable_descriptor(); diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs new file mode 100644 index 0000000..1acf93a --- /dev/null +++ b/crates/runner/src/launch.rs @@ -0,0 +1,1340 @@ +//! Managed launch of a prepared execution by its owner (CSRG-U4). +//! +//! A [`PreparedExecution`] (CSRG-U3) is launched once, in this order: +//! +//! 1. **Local checks** before anything is committed: the prepared command, its deadline, a +//! usable launch helper ([`Launcher`]), and the program resolved to an absolute path on the +//! execution's own `PATH`. A failure here cancels the unstarted attempt, as U3 does. +//! 2. **`BeginLaunch`, exactly once**, in a bounded session of the registered owner. Only its +//! first answer carries the attempt's one-time permit. An error answer committed nothing, so +//! the attempt is cancelled. A lost or unusable answer is never replaced by another +//! `BeginLaunch`: the attempt is looked up once, cancelled if it is still prepared, reported +//! as never received (`AbandonLaunch`) if it is committed and unclaimed, and otherwise +//! reported as found or unknown. +//! 3. **The helper**, DevGuard's `devguard-launch`, started by CodeSpace's own pipe or PTY +//! spawner as this process's direct child, filling the slot the preparation took. Only the +//! helper receives the permit carrier and its transcript writer; this process's copies close +//! as soon as the spawn returns. A failed spawn means no helper exists, which is reported +//! (`AbandonLaunch`), so DevGuard releases the grant as never started. +//! 4. **The transcript**, read to its end on its own thread. The helper reports READY only after +//! DevGuard has bound its process group as the attempt's scope, applied the policy and +//! authorized the run; it then execs the executable, which keeps its PID, and the transcript +//! closes. READY then close is a running payload; READY then `exec_failed` is a payload that +//! could not be started; a refusal, a failure or an end before READY means the executable never +//! ran, and once the helper is reaped this owner reports that it holds no helper. +//! +//! The process is CodeSpace's from the spawn on, exactly as an ungoverned one: output, stdin, +//! resize, timeout, termination and reaping are unchanged. CodeSpace waits at most +//! [`Launcher::ready_bound`] for the transcript; when it has not ended by then, the launch is +//! reported with an unknown dispatch, as a spawn whose result could not be confirmed, and the +//! transcript is still read to its end in the background. +//! +//! Not here (CSRG-U5): observation of the scope before CodeSpace reaps the process. DevGuard +//! releases a scope whose members all ended with its root; a descendant that outlives the root +//! keeps the attempt charged, and one CodeSpace reaped before DevGuard saw it leaves the attempt +//! suspect. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use codespace_devguard::admission::{self as dg, Abandon, Answer}; +use codespace_devguard::launch::{ + check_helper, HelperProblem, HelperStage, LaunchAnswer, TranscriptEnd, +}; +use codespace_domain::{ProcessId, ProcessState, ResourceAuthorityErrorCode}; +use codespace_policy::{NetworkAxis, Workspace}; +use serde::{Deserialize, Serialize}; + +use crate::admission::{ + abandoned, attempt_state, call, cancelled, AdmissionAccount, Applied, AppliedControl, + AttemptState, Call, ManagedPreparation, PreparationOutcome, PreparedExecution, +}; +use crate::process::spawn_env; +use crate::registration::error_code; +use crate::{RunnerCwd, RunnerExecRequest}; + +/// How long a launch waits for the helper's transcript by default. +pub const DEFAULT_READY_BOUND: Duration = Duration::from_secs(10); +/// How long a helper that never ran the executable is given to exit before it is killed. +const SETTLE_BOUND: Duration = Duration::from_secs(5); + +/// Where the owner's launch helper is, and how long a launch waits for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Launcher { + helper: PathBuf, + ready_bound: Duration, +} + +impl Launcher { + /// DevGuard's `devguard-launch` at `helper`, an absolute path. Whether the file can serve is + /// checked before each launch ([`check_helper`]), so a helper replaced or removed later is + /// never handed a permit. + pub fn new(helper: PathBuf) -> Result { + if !helper.is_absolute() { + return Err(HelperProblem::NotAbsolute.describe()); + } + Ok(Self { + helper, + ready_bound: DEFAULT_READY_BOUND, + }) + } + + /// Wait at most `bound` for the helper's transcript. + pub fn with_ready_bound(mut self, bound: Duration) -> Self { + self.ready_bound = bound; + self + } + + pub fn helper(&self) -> &Path { + &self.helper + } + + pub fn ready_bound(&self) -> Duration { + self.ready_bound + } + + /// Whether the helper can be used now. + pub fn usable(&self) -> Result<(), HelperProblem> { + check_helper(&self.helper) + } + + /// What the helper will be asked to run, or why this owner cannot launch `req` in `ws`. + fn plan(&self, ws: &Workspace, req: &RunnerExecRequest) -> Result { + if let Err(problem) = self.usable() { + return Err(PreparationOutcome::HelperUnusable { + problem: helper_problem(problem), + }); + } + // The Linux command sandbox wraps the command in its own helper, whose process the + // resource authority would govern instead; DevGuard cannot register there anyway. + if crate::linux_sandbox_available() { + return Err(PreparationOutcome::SandboxUnsupported); + } + if matches!(req.policy.network, NetworkAxis::Enabled) { + return Err(PreparationOutcome::NetworkUnsupported); + } + let cwd = match req.cwd { + RunnerCwd::WorkspaceRoot => ws.root.clone(), + }; + let env = spawn_env(&cwd, req, false); + let name = req.argv.first().map(String::as_str).unwrap_or_default(); + let Some(program) = resolve_program(name, &cwd, env.get("PATH").map(String::as_str)) else { + return Err(PreparationOutcome::ProgramUnavailable); + }; + Ok(Plan { + program, + args: req.argv.iter().skip(1).map(OsString::from).collect(), + }) + } +} + +/// The helper's command: the program by absolute path, and its arguments. +struct Plan { + program: PathBuf, + args: Vec, +} + +/// `name` resolved as the spawn would resolve it: a name with a slash is a path, relative to the +/// working directory; a bare name is searched on `path` (`/usr/bin:/bin` when unset, as +/// `execvp` does), an empty entry meaning the working directory. The first regular file this +/// user may execute wins. The helper runs the executable by this absolute path, which is +/// therefore its `argv[0]`. +pub fn resolve_program(name: &str, cwd: &Path, path: Option<&str>) -> Option { + if name.is_empty() { + return None; + } + let anchored = |dir: &str| -> PathBuf { + match dir { + "" => cwd.to_owned(), + dir if Path::new(dir).is_absolute() => PathBuf::from(dir), + dir => cwd.join(dir), + } + }; + if name.contains('/') { + let candidate = anchored(name); + return executable(&candidate).then_some(candidate); + } + path.unwrap_or("/usr/bin:/bin") + .split(':') + .map(|dir| anchored(dir).join(name)) + .find(|candidate| executable(candidate)) +} + +fn executable(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + if !std::fs::metadata(path).is_ok_and(|meta| meta.is_file()) { + return false; + } + let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: access reads a NUL-terminated path. + unsafe { libc::access(name.as_ptr(), libc::X_OK) == 0 } +} + +/// A launch's phases, in the order they are reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LaunchPhase { + /// Admitted and prepared (CSRG-U3). + Prepared, + /// `BeginLaunch` answered with the permit. + LaunchCommitted, + /// The helper process exists, as this process's child. + HelperCreated, + /// DevGuard bound the helper's process group as the attempt's scope. + ScopeBound, + /// DevGuard applied the scope's policy and read it back. + PoliciesApplied, + /// DevGuard authorized the run and the helper reported READY. Not evidence that the + /// executable started. + HelperReady, + /// The helper attempted the executable. + PayloadExecAttempted, + /// The executable replaced the helper. + PayloadRunning, + /// The executable could not be started. + PayloadFailed, + /// DevGuard fenced the launch after it was committed; it settles through its scope. + Draining, + /// DevGuard returned the attempt's reservation. + Released, +} + +/// Whether the transcript confirmed the launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LaunchDispatch { + /// READY, then the transcript closed: the executable started. + Confirmed, + /// The helper exists and its transcript did not settle in time, or not well formed: the + /// executable may or may not have started. + Unknown, +} + +/// A launched governed execution: a CodeSpace process like any other. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedLaunch { + pub attempt_id: String, + pub process_id: ProcessId, + pub dispatch: LaunchDispatch, + pub phases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, + /// The scope's root as DevGuard bound it: the helper's PID, which the executable kept. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_root_pid: Option, +} + +/// How a governed execution's launch ended for its request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "launch", rename_all = "snake_case")] +pub enum LaunchReport { + Launched(ManagedLaunch), + /// The command was not started; the report says why and what is known of the attempt. + NotLaunched(ManagedPreparation), +} + +impl LaunchReport { + pub fn attempt_id(&self) -> &str { + match self { + Self::Launched(launch) => &launch.attempt_id, + Self::NotLaunched(report) => &report.attempt_id, + } + } + + pub fn process_id(&self) -> &ProcessId { + match self { + Self::Launched(launch) => &launch.process_id, + Self::NotLaunched(report) => &report.process_id, + } + } +} + +fn helper_problem(problem: HelperProblem) -> crate::admission::HelperProblem { + use crate::admission::HelperProblem as Problem; + match problem { + HelperProblem::NotAbsolute => Problem::NotAbsolute, + HelperProblem::NotAFile => Problem::NotAFile, + HelperProblem::NotExecutable => Problem::NotExecutable, + HelperProblem::NotPrivate => Problem::NotPrivate, + } +} + +fn helper_stage(stage: HelperStage) -> crate::admission::HelperStage { + use crate::admission::HelperStage as Stage; + match stage { + HelperStage::Scope => Stage::Scope, + HelperStage::Clamp => Stage::Clamp, + HelperStage::Report => Stage::Report, + HelperStage::Permit => Stage::Permit, + HelperStage::Connect => Stage::Connect, + HelperStage::Other => Stage::Other, + } +} + +impl PreparedExecution { + /// Launch the prepared execution once under the resource authority, as the module states, + /// or report why its command was not started. Without a `launcher` the owner cannot launch: + /// the unstarted attempt is cancelled and the outcome is `launch_unavailable`, as in U3. + pub async fn launch_managed( + mut self, + request: &RunnerExecRequest, + launcher: Option<&Launcher>, + ) -> LaunchReport { + if let Some(outcome) = self.refusal(request) { + let attempt = self.settle().await; + return LaunchReport::NotLaunched(self.report(outcome, attempt)); + } + let Some(launcher) = launcher else { + let attempt = self.settle().await; + return LaunchReport::NotLaunched( + self.report(PreparationOutcome::LaunchUnavailable, attempt), + ); + }; + let plan = match launcher.plan(&self.ws, request) { + Ok(plan) => plan, + Err(outcome) => { + let attempt = self.settle().await; + return LaunchReport::NotLaunched(self.report(outcome, attempt)); + } + }; + // From here the launch may be committed: a drop must not cancel it. + self.settled = true; + let mut phases = vec![LaunchPhase::Prepared]; + let permit = match begin_launch(&self).await { + LaunchAnswer::Granted { permit, .. } => permit, + LaunchAnswer::Refused(code) => { + // An error answer committed nothing; the attempt is as it was. + let attempt = cancelled(call(&self.authority, &self.admission, Call::Cancel).await); + return self.not_launched( + PreparationOutcome::LaunchRefused { + error_code: error_code(code), + }, + attempt, + ); + } + LaunchAnswer::NotSent(registration) => { + let attempt = cancelled(call(&self.authority, &self.admission, Call::Cancel).await); + return self.not_launched( + PreparationOutcome::AuthorityUnavailable { + registration: crate::registration::registration_state(registration.state), + error_code: registration.error_code.map(error_code), + }, + attempt, + ); + } + LaunchAnswer::Unknown => { + let attempt = resolve_launch(&self).await; + return self.not_launched(PreparationOutcome::LaunchUnknown, attempt); + } + }; + phases.push(LaunchPhase::LaunchCommitted); + let invocation = match self.authority.helper( + launcher.helper(), + &self.admission, + permit, + &plan.program, + &plan.args, + ) { + Ok(invocation) => invocation, + Err(_) => { + let attempt = self.abandon(Abandon::SpawnFailed).await; + return self.not_launched(PreparationOutcome::HelperNotCreated, attempt); + } + }; + let Some(slot) = self.slot.take() else { + drop(invocation); + let attempt = self.abandon(Abandon::SpawnFailed).await; + return self.not_launched(PreparationOutcome::HelperNotCreated, attempt); + }; + let (spawned, transcript) = self + .runner + .spawn_managed(&self.ws, request.clone(), slot, invocation) + .await; + if spawned.is_err() { + // No helper exists, and none will be created for this grant. + let attempt = self.abandon(Abandon::SpawnFailed).await; + return self.not_launched(PreparationOutcome::HelperNotCreated, attempt); + } + phases.push(LaunchPhase::HelperCreated); + let (sender, ended) = tokio::sync::oneshot::channel(); + tokio::task::spawn_blocking(move || { + let _ = sender.send(transcript.read()); + }); + let mut ended = ended; + let end = match tokio::time::timeout(launcher.ready_bound(), &mut ended).await { + Ok(Ok(end)) => end, + Ok(Err(_)) => TranscriptEnd::Uncertain, + Err(_) => { + // Not settled in time: the process is CodeSpace's either way. The transcript + // is still read to its end, and a helper that never ran the executable is + // still reported once it is reaped. + let settle = self.settler(); + tokio::spawn(async move { + if let Ok(end) = ended.await { + if never_ran(end) { + settle.after_reap().await; + } + } + }); + return self.launched(phases, LaunchDispatch::Unknown).await; + } + }; + match end { + TranscriptEnd::Started => { + phases.extend([ + LaunchPhase::ScopeBound, + LaunchPhase::PoliciesApplied, + LaunchPhase::HelperReady, + LaunchPhase::PayloadExecAttempted, + LaunchPhase::PayloadRunning, + ]); + self.launched(phases, LaunchDispatch::Confirmed).await + } + TranscriptEnd::Uncertain => self.launched(phases, LaunchDispatch::Unknown).await, + TranscriptEnd::ExecFailed { errno } => { + // The helper claimed the grant before READY, so its scope settles it; the + // helper exits 126 or 127 at once. + self.settler().reaped().await; + let attempt = self.looked_up().await; + self.not_launched(PreparationOutcome::ExecFailed { errno }, attempt) + } + end => { + let outcome = match end { + TranscriptEnd::Refused { code } => PreparationOutcome::HelperRefused { + error_code: error_code(code), + }, + TranscriptEnd::Failed { stage } => PreparationOutcome::HelperFailed { + stage: helper_stage(stage), + }, + _ => PreparationOutcome::HelperEnded, + }; + let attempt = self.settler().after_reap().await; + self.not_launched(outcome, attempt) + } + } + } + + /// The prepared command's own refusal: another command than the prepared one, or past its + /// deadline (CSRG-U3). + fn refusal(&self, request: &RunnerExecRequest) -> Option { + if crate::admission::meaning(&self.ws, request) + .digest() + .as_deref() + != Some(self.identity.digest.as_str()) + || request.process_id != self.identity.process_id + { + Some(PreparationOutcome::DigestMismatch) + } else if std::time::Instant::now() >= self.deadline { + Some(PreparationOutcome::Expired) + } else { + None + } + } + + fn not_launched(&mut self, outcome: PreparationOutcome, attempt: AttemptState) -> LaunchReport { + self.slot = None; + LaunchReport::NotLaunched(self.report(outcome, attempt)) + } + + /// The launched report, with what DevGuard's record shows of the run. + async fn launched( + &mut self, + phases: Vec, + dispatch: LaunchDispatch, + ) -> LaunchReport { + let mut account = self.account; + let mut scope_root_pid = None; + if dispatch == LaunchDispatch::Confirmed { + if let Answer::Attempt(attempt) = + call(&self.authority, &self.admission, Call::Lookup).await + { + scope_root_pid = attempt.scope.map(|scope| scope.root_pid); + account.applied = match attempt.applied { + Some(applied) => Applied::Launched { + cpu: AppliedControl::from(applied.cpu), + memory: AppliedControl::from(applied.memory), + pids: AppliedControl::from(applied.pids), + }, + None => Applied::Unknown, + }; + } else { + account.applied = Applied::Unknown; + } + } else { + account.applied = Applied::Unknown; + } + LaunchReport::Launched(ManagedLaunch { + attempt_id: self.identity.attempt_id.clone(), + process_id: self.identity.process_id.clone(), + dispatch, + phases, + account: Some(account), + scope_root_pid, + }) + } + + async fn abandon(&self, reason: Abandon) -> AttemptState { + abandoned(call(&self.authority, &self.admission, Call::Abandon(reason)).await) + } + + async fn looked_up(&self) -> AttemptState { + match call(&self.authority, &self.admission, Call::Lookup).await { + Answer::Attempt(attempt) => attempt_state(&attempt), + _ => AttemptState::Unknown, + } + } + + fn settler(&self) -> Settler { + Settler { + authority: self.authority.clone(), + admission: self.admission.clone(), + runner: self.runner.clone(), + process_id: self.identity.process_id.clone(), + } + } +} + +/// Whether a transcript end proves the executable never ran. +fn never_ran(end: TranscriptEnd) -> bool { + matches!( + end, + TranscriptEnd::Refused { .. } + | TranscriptEnd::Failed { .. } + | TranscriptEnd::EndedBeforeReady + ) +} + +/// Settles a launch whose helper never ran the executable, once CodeSpace has reaped it. +struct Settler { + authority: Arc, + admission: dg::Admission, + runner: crate::InProcessRunner, + process_id: ProcessId, +} + +impl Settler { + /// Wait until CodeSpace's reaper has reaped the helper, killing it if it has not exited + /// within its bound. Whether it was reaped. + async fn reaped(&self) -> bool { + if self.exited_within(SETTLE_BOUND).await { + return true; + } + let _ = self.runner.kill_host(&self.process_id); + self.exited_within(SETTLE_BOUND).await + } + + async fn exited_within(&self, bound: Duration) -> bool { + let deadline = tokio::time::Instant::now() + bound; + loop { + match self.runner.host_process_status(&self.process_id) { + Ok(status) if status.state == ProcessState::Exited => return true, + Ok(_) => {} + // An evicted handle was reaped before it could be evicted. + Err(_) => return true, + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// Once the helper is reaped, report that this owner holds no helper for the grant: an + /// unclaimed grant is then released as never started; a claimed one settles through its + /// scope. Without the reap there is nothing to report, and the attempt stays unknown. + async fn after_reap(&self) -> AttemptState { + if !self.reaped().await { + return AttemptState::Unknown; + } + abandoned( + call( + &self.authority, + &self.admission, + Call::Abandon(Abandon::HelperExited), + ) + .await, + ) + } +} + +/// `BeginLaunch`, once, on its own thread. +async fn begin_launch(prepared: &PreparedExecution) -> LaunchAnswer { + let (authority, admission) = (prepared.authority.clone(), prepared.admission.clone()); + tokio::task::spawn_blocking(move || authority.begin_launch(&admission)) + .await + .unwrap_or(LaunchAnswer::Unknown) +} + +/// After a `BeginLaunch` without a usable answer: look the attempt up once, and settle it where +/// its precondition holds. Still prepared: the launch was not committed, so it is cancelled. +/// Committed and unclaimed: the permit never arrived and no helper exists, so this owner says +/// so. Anything else is reported as found; no answer leaves it unknown. +async fn resolve_launch(prepared: &PreparedExecution) -> AttemptState { + let (authority, admission) = (&prepared.authority, &prepared.admission); + match call(authority, admission, Call::Lookup).await { + Answer::Attempt(attempt) if attempt.phase == dg::Phase::Prepared => { + cancelled(call(authority, admission, Call::Cancel).await) + } + Answer::Attempt(attempt) + if attempt.phase == dg::Phase::LaunchCommitted && attempt.scope.is_none() => + { + abandoned( + call( + authority, + admission, + Call::Abandon(Abandon::GrantNotReceived), + ) + .await, + ) + } + Answer::Attempt(attempt) => attempt_state(&attempt), + Answer::Refused(_) | Answer::NotSent(_) | Answer::Unknown => AttemptState::Unknown, + } +} + +/// The resource authority's error code for a launch refusal. +pub(crate) fn launch_refusal(code: ResourceAuthorityErrorCode) -> codespace_domain::ErrorCode { + use codespace_domain::ErrorCode; + match code { + ResourceAuthorityErrorCode::ResourceUnavailable => ErrorCode::ResourceUnavailable, + ResourceAuthorityErrorCode::ResourcePolicyUnsupported => { + ErrorCode::ResourcePolicyUnsupported + } + _ => ErrorCode::ResourceAuthorityUnavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admission::tests::{ + attempt, governed, prepared, record, request, runner, Fake, Grant, ABANDON, ADMIT, BEGIN, + CANCEL, LOOKUP, + }; + use crate::admission::Preparer; + use crate::{InProcessRunner, Runner, RunnerReadProcess}; + use codespace_devguard as devguard; + use codespace_domain::ErrorCode; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::fs::PermissionsExt; + + /// A private directory that [`check_helper`] accepts for a stand-in helper. + fn helper_directory() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("cs-launch-") + .tempdir_in(if cfg!(target_os = "macos") { + "/private/tmp" + } else { + "/tmp" + }) + .unwrap() + } + + /// A stand-in for DevGuard's launch helper. It takes the helper's arguments, runs `entry`, + /// consumes the permit, then runs `behavior`, which writes transcript lines with `report` + /// as the real helper does. It cannot present a grant, so it tests CodeSpace's side only. + fn stand_in(dir: &Path, name: &str, entry: &str, behavior: &str) -> PathBuf { + let script = format!( + r#"#!/bin/sh +while [ "$1" != "--" ]; do + case "$1" in + --permit-fd) permit="$2" ;; + --report-fd) report="$2" ;; + esac + shift 2 +done +shift +{entry} +eval "IFS= read -r grant <&$permit" +eval "exec $permit<&-" +report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} +{behavior} +"# + ); + let path = dir.join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path + } + + const READY: &str = r#"report '{"phase":"ready"}'; eval "exec $report>&-"; exec "$@""#; + + fn launcher(helper: PathBuf) -> Launcher { + Launcher::new(helper) + .unwrap() + .with_ready_bound(Duration::from_secs(5)) + } + + async fn launch( + fake: &Arc, + runner: &InProcessRunner, + ws: &Workspace, + req: RunnerExecRequest, + launcher: Option, + ) -> LaunchReport { + Preparer::new(fake.clone(), runner.clone()) + .with_launcher(launcher) + .prepare_and_launch(ws.clone(), req, "cs-attempt-u4".into()) + .await + } + + /// The process's output once it has ended. + async fn finished_output(runner: &InProcessRunner, process_id: &ProcessId) -> String { + let mut chunk = String::new(); + for _ in 0..250 { + let read = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = read.chunk.replace("\r\n", "\n"); + if read.eof { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + chunk + } + + async fn settled_slots(runner: &InProcessRunner) -> usize { + for _ in 0..250 { + if runner.occupied_slots() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + runner.occupied_slots() + } + + fn marker_request(root: &Path, process: &str) -> (PathBuf, RunnerExecRequest) { + let marker = root.join(format!("ran-{process}")); + let req = request(&["/usr/bin/touch", marker.to_str().unwrap()], process); + (marker, req) + } + + fn launched(report: LaunchReport) -> ManagedLaunch { + match report { + LaunchReport::Launched(launch) => launch, + LaunchReport::NotLaunched(report) => panic!("not launched: {report:?}"), + } + } + + fn not_launched(report: LaunchReport) -> ManagedPreparation { + match report { + LaunchReport::NotLaunched(report) => report, + LaunchReport::Launched(launch) => panic!("launched: {launch:?}"), + } + } + + const STARTED: [LaunchPhase; 8] = [ + LaunchPhase::Prepared, + LaunchPhase::LaunchCommitted, + LaunchPhase::HelperCreated, + LaunchPhase::ScopeBound, + LaunchPhase::PoliciesApplied, + LaunchPhase::HelperReady, + LaunchPhase::PayloadExecAttempted, + LaunchPhase::PayloadRunning, + ]; + + #[tokio::test] + async fn a_granted_launch_starts_the_command_once_through_each_spawner() { + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for tty in [false, true] { + let runner = runner(); + let applied = dg::Attempt { + scope: Some(dg::Scope { + kind: dg::ScopeKind::ObservedProcessGroup, + root_pid: 4242, + }), + applied: Some(dg::Applied { + cpu: dg::Application::Applied, + memory: dg::Application::Applied, + pids: dg::Application::Applied, + }), + ..record(dg::Phase::RunAuthorized, None) + }; + let fake = Fake::admitting([prepared()]).then_lookup(Answer::Attempt(applied)); + let process = format!("proc-ready-{tty}"); + let mut req = request( + &[ + "/bin/sh", + "-c", + "if [ -t 0 ]; then echo tty:$0; else echo pipe:$0; fi", + ], + &process, + ); + req.tty = tty; + let launch = + launched(launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await); + assert_eq!( + (launch.dispatch, launch.phases.as_slice()), + (LaunchDispatch::Confirmed, STARTED.as_slice()) + ); + assert_eq!(launch.process_id.0, process); + assert_eq!(launch.scope_root_pid, Some(4242)); + assert_eq!( + launch.account.unwrap().applied, + Applied::Launched { + cpu: AppliedControl::Applied, + memory: AppliedControl::Applied, + pids: AppliedControl::Applied, + } + ); + // The helper exec'd the command, which runs on CodeSpace's own pipe or PTY, with + // its resolved path as argv[0]. + let expected = if tty { + "tty:/bin/sh\n" + } else { + "pipe:/bin/sh\n" + }; + assert_eq!(finished_output(&runner, &launch.process_id).await, expected); + let status = runner.process_status(&launch.process_id).await.unwrap(); + assert_eq!( + (status.state, status.exit_code), + (ProcessState::Exited, Some(0)) + ); + // Asked once to admit, once to launch and once for the record; never cancelled. + assert_eq!( + ( + fake.count(ADMIT), + fake.count(BEGIN), + fake.count(LOOKUP), + fake.count(CANCEL), + fake.count(ABANDON) + ), + (1, 1, 1, 0, 0) + ); + assert_eq!(settled_slots(&runner).await, 0); + } + } + + #[tokio::test] + async fn a_helper_that_never_runs_the_command_is_reported_once_it_is_reaped() { + let helpers = helper_directory(); + let refused = r#"report '{"phase":"refused","code":"invalid_transition","message":"fenced"}'; exit 125"#; + let failed = + r#"report '{"phase":"failed","stage":"connect","message":"absent"}'; exit 125"#; + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let cases = [ + ( + stand_in(helpers.path(), "refused", "", refused), + PreparationOutcome::HelperRefused { + error_code: ResourceAuthorityErrorCode::InvalidTransition, + }, + ErrorCode::ResourceAuthorityUnavailable, + ), + ( + stand_in(helpers.path(), "failed", "", failed), + PreparationOutcome::HelperFailed { + stage: crate::admission::HelperStage::Connect, + }, + ErrorCode::ResourceAuthorityUnavailable, + ), + ( + // Killed before it reported anything. + stand_in(helpers.path(), "dies", "", "kill -KILL $$"), + PreparationOutcome::HelperEnded, + ErrorCode::ProcessSpawnFailed, + ), + ]; + for (helper, outcome, code) in cases { + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let (marker, mut req) = marker_request(dir.path(), &format!("proc-never-{tty}")); + req.tty = tty; + let report = not_launched( + launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await, + ); + assert_eq!( + (report.outcome, report.attempt), + (outcome, AttemptState::Released), + "{helper:?} tty={tty}" + ); + assert!(!marker.exists(), "the command ran"); + // The helper was reaped first; then this owner said it holds no helper. + assert_eq!(fake.count(ABANDON), 1); + assert_eq!(*fake.abandoned_for.lock().unwrap(), [Abandon::HelperExited]); + assert_eq!(fake.count(CANCEL), 0); + assert_eq!(settled_slots(&runner).await, 0); + let body = report.into_error_body("gov"); + assert_eq!(body.code, code); + assert!( + body.message.ends_with( + "attempt `cs-attempt-u4` for process_id `proc-never-false`: released; \ + the command was not started" + ) || body.message.ends_with( + "attempt `cs-attempt-u4` for process_id `proc-never-true`: released; \ + the command was not started" + ), + "{}", + body.message + ); + } + } + } + + #[tokio::test] + async fn a_report_for_a_claimed_grant_leaves_it_to_its_scope() { + // Refused after the helper claimed the grant, as when DevGuard cannot read the policy + // back: the owner's report cannot release it, and the attempt stays launched. + let helpers = helper_directory(); + let refused = r#"report '{"phase":"refused","code":"resource_control_unavailable","message":"readback"}'; exit 125"#; + let helper = stand_in(helpers.path(), "refused-claimed", "", refused); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]) + .then_abandon(Answer::Attempt(record(dg::Phase::ScopeBound, None))); + let (marker, req) = marker_request(dir.path(), "proc-claimed"); + let report = not_launched(launch(&fake, &runner, &ws, req, Some(launcher(helper))).await); + assert_eq!( + (report.outcome, report.attempt), + ( + PreparationOutcome::HelperRefused { + error_code: ResourceAuthorityErrorCode::ResourceControlUnavailable, + }, + AttemptState::Launched + ) + ); + assert_eq!(*fake.abandoned_for.lock().unwrap(), [Abandon::HelperExited]); + assert!(!marker.exists()); + } + + #[tokio::test] + async fn an_exec_failure_after_ready_is_not_reported_as_unstarted() { + let helpers = helper_directory(); + let helper = stand_in( + helpers.path(), + "exec-failed", + "", + r#"report '{"phase":"ready"}'; report '{"phase":"exec_failed","errno":13}'; exit 126"#, + ); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]) + .then_lookup(Answer::Attempt(record(dg::Phase::RunAuthorized, None))); + let (marker, req) = marker_request(dir.path(), "proc-exec"); + let report = not_launched(launch(&fake, &runner, &ws, req, Some(launcher(helper))).await); + // The helper claimed the grant, so only its scope settles it: no owner report. + assert_eq!( + (report.outcome, report.attempt), + ( + PreparationOutcome::ExecFailed { errno: 13 }, + AttemptState::Launched + ) + ); + assert_eq!(fake.count(ABANDON), 0); + assert!(!marker.exists()); + let body = report.into_error_body("gov"); + assert_eq!(body.code, ErrorCode::ProcessSpawnFailed); + assert!(body.message.contains("errno 13"), "{}", body.message); + } + + #[tokio::test] + async fn a_helper_that_cannot_be_started_is_reported_and_nothing_runs() { + let helpers = helper_directory(); + // Executable and private, but its interpreter does not exist: the spawn fails. + let helper = helpers.path().join("broken"); + std::fs::write(&helper, "#!/nonexistent/interpreter\n").unwrap(); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let (marker, mut req) = marker_request(dir.path(), &format!("proc-spawn-{tty}")); + req.tty = tty; + let report = not_launched( + launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await, + ); + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::HelperNotCreated, AttemptState::Released) + ); + assert_eq!(*fake.abandoned_for.lock().unwrap(), [Abandon::SpawnFailed]); + assert!(!marker.exists()); + assert_eq!(runner.occupied_slots(), 0); + assert!(runner + .host_workspace_of(&format!("proc-spawn-{tty}")) + .is_none()); + assert_eq!( + report.into_error_body("gov").code, + ErrorCode::ProcessSpawnFailed + ); + } + } + + #[tokio::test] + async fn a_transcript_that_does_not_settle_in_time_is_an_unknown_dispatch() { + let helpers = helper_directory(); + // It becomes `sleep`, as the real helper becomes the command, so the transcript writer + // ends with it. + let slow = stand_in(helpers.path(), "slow", "", "exec sleep 30"); + let garbage = stand_in( + helpers.path(), + "garbage", + "", + r#"report 'not json'; eval "exec $report>&-"; exec "$@""#, + ); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + // Never READY within the bound: the process is CodeSpace's either way. + let fake = Fake::admitting([prepared()]); + let req = request(&["/bin/echo", "late"], "proc-slow"); + let launch_report = launched( + launch( + &fake, + &runner, + &ws, + req, + Some(launcher(slow).with_ready_bound(Duration::from_millis(300))), + ) + .await, + ); + assert_eq!(launch_report.dispatch, LaunchDispatch::Unknown); + assert_eq!( + launch_report.phases, + [ + LaunchPhase::Prepared, + LaunchPhase::LaunchCommitted, + LaunchPhase::HelperCreated + ] + ); + assert_eq!(launch_report.account.unwrap().applied, Applied::Unknown); + let status = runner + .process_status(&launch_report.process_id) + .await + .unwrap(); + assert_eq!(status.state, ProcessState::Running); + // Terminated before READY: once it is reaped, the owner reports it holds no helper. + runner.terminate(&launch_report.process_id).await.unwrap(); + for _ in 0..250 { + if fake.count(ABANDON) == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(*fake.abandoned_for.lock().unwrap(), [Abandon::HelperExited]); + let status = runner + .process_status(&launch_report.process_id) + .await + .unwrap(); + assert_eq!( + status.termination, + Some(codespace_domain::ProcessTermination::Terminated) + ); + + // A transcript that is not well formed: whether the command started is not known, and + // nothing is reported for the grant. + let fake = Fake::admitting([prepared()]); + let req = request(&["/bin/echo", "maybe"], "proc-garbage"); + let launch_report = + launched(launch(&fake, &runner, &ws, req, Some(launcher(garbage))).await); + assert_eq!(launch_report.dispatch, LaunchDispatch::Unknown); + assert_eq!( + finished_output(&runner, &launch_report.process_id).await, + "maybe\n" + ); + assert_eq!(fake.count(ABANDON), 0); + } + + #[tokio::test] + async fn begin_launch_is_sent_once_and_never_after_an_unknown_answer() { + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let committed = Answer::Attempt(record(dg::Phase::LaunchCommitted, None)); + let cases = [ + // A refusal committed nothing: cancelled. + ( + Fake::admitting([prepared()]) + .then_grant(Grant::Refused(devguard::ErrorCode::InvalidTransition)), + PreparationOutcome::LaunchRefused { + error_code: ResourceAuthorityErrorCode::InvalidTransition, + }, + AttemptState::Cancelled, + (1, 0, 0), + ErrorCode::ResourceAuthorityUnavailable, + ), + ( + Fake::admitting([prepared()]) + .then_grant(Grant::Refused(devguard::ErrorCode::ResourceUnavailable)), + PreparationOutcome::LaunchRefused { + error_code: ResourceAuthorityErrorCode::ResourceUnavailable, + }, + AttemptState::Cancelled, + (1, 0, 0), + ErrorCode::ResourceUnavailable, + ), + // The session never sent it: cancelled. + ( + Fake::admitting([prepared()]).then_grant(Grant::NotSent), + PreparationOutcome::AuthorityUnavailable { + registration: codespace_domain::ResourceRegistrationState::Unavailable, + error_code: Some(ResourceAuthorityErrorCode::ResourceControlUnavailable), + }, + AttemptState::Cancelled, + (1, 0, 0), + ErrorCode::ResourceAuthorityUnavailable, + ), + // No answer, and still prepared: not committed, so cancelled. + ( + Fake::admitting([prepared()]) + .then_grant(Grant::Unknown) + .then_lookup(prepared()), + PreparationOutcome::LaunchUnknown, + AttemptState::Cancelled, + (1, 1, 0), + ErrorCode::ResourceAuthorityUnavailable, + ), + // No answer, committed and unclaimed: the permit never arrived. + ( + Fake::admitting([prepared()]) + .then_grant(Grant::Unknown) + .then_lookup(committed), + PreparationOutcome::LaunchUnknown, + AttemptState::Released, + (0, 1, 1), + ErrorCode::ResourceAuthorityUnavailable, + ), + // No answer, and no record either: unknown. + ( + Fake::admitting([prepared()]).then_grant(Grant::Unknown), + PreparationOutcome::LaunchUnknown, + AttemptState::Unknown, + (0, 1, 0), + ErrorCode::AdmissionUnknown, + ), + ]; + for (fake, outcome, state, (cancels, lookups, abandons), code) in cases { + let runner = runner(); + let (marker, req) = marker_request(dir.path(), "proc-begin"); + let report = not_launched( + launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await, + ); + assert_eq!((report.outcome, report.attempt), (outcome, state)); + assert_eq!(fake.count(BEGIN), 1, "BeginLaunch is never sent again"); + assert_eq!( + (fake.count(CANCEL), fake.count(LOOKUP), fake.count(ABANDON)), + (cancels, lookups, abandons), + "{outcome:?}" + ); + if abandons == 1 { + assert_eq!( + *fake.abandoned_for.lock().unwrap(), + [Abandon::GrantNotReceived] + ); + } + assert!(!marker.exists(), "no helper may start"); + assert_eq!(runner.occupied_slots(), 0); + let body = report.into_error_body("gov"); + assert_eq!(body.code, code, "{outcome:?}"); + assert!( + body.message.ends_with("nothing was started"), + "{}", + body.message + ); + } + } + + #[tokio::test] + async fn nothing_is_committed_when_the_owner_cannot_launch() { + let helpers = helper_directory(); + let ready = stand_in(helpers.path(), "ready", "", READY); + let shared = helpers.path().join("shared"); + std::fs::copy(&ready, &shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o770)).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let mut network = request(&["/bin/echo"], "proc-local"); + network.policy.network = NetworkAxis::Enabled; + let cases: [( + Option, + RunnerExecRequest, + PreparationOutcome, + ErrorCode, + ); 4] = [ + // No launch helper: as in U3. + ( + None, + request(&["/bin/echo"], "proc-local"), + PreparationOutcome::LaunchUnavailable, + ErrorCode::ManagedLaunchUnavailable, + ), + // A helper others could replace is never handed a permit. + ( + Some(launcher(shared)), + request(&["/bin/echo"], "proc-local"), + PreparationOutcome::HelperUnusable { + problem: crate::admission::HelperProblem::NotPrivate, + }, + ErrorCode::ManagedLaunchUnavailable, + ), + ( + Some(launcher(ready.clone())), + request(&["no-such-codespace-program"], "proc-local"), + PreparationOutcome::ProgramUnavailable, + ErrorCode::ProcessSpawnFailed, + ), + ( + Some(launcher(ready.clone())), + network, + PreparationOutcome::NetworkUnsupported, + ErrorCode::ProcessSpawnFailed, + ), + ]; + for (launcher, req, outcome, code) in cases { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let report = not_launched(launch(&fake, &runner, &ws, req, launcher).await); + assert_eq!( + (report.outcome, report.attempt), + (outcome, AttemptState::Cancelled) + ); + assert_eq!((fake.count(BEGIN), fake.count(CANCEL)), (0, 1)); + assert_eq!(runner.occupied_slots(), 0); + assert_eq!(report.into_error_body("gov").code, code); + } + // Past its deadline, or another command: cancelled, never launched. + let runner = runner(); + let fake = Fake::admitting([prepared()]).then_cancel(attempt(dg::Phase::Expired, None)); + let req = request(&["/bin/echo"], "proc-late"); + let late = Preparer::new(fake.clone(), runner.clone()) + .with_ttl_bound(Duration::from_millis(20)) + .prepare(&ws, req.clone(), "cs-late".into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(40)).await; + let report = not_launched( + late.launch_managed(&req, Some(&launcher(ready.clone()))) + .await, + ); + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::Expired, AttemptState::Expired) + ); + let fake2 = Fake::admitting([prepared()]); + let mismatched = Preparer::new(fake2.clone(), runner.clone()) + .prepare(&ws, req.clone(), "cs-other".into()) + .await + .unwrap(); + let mut other = req.clone(); + other.argv.push("again".into()); + let report = not_launched( + mismatched + .launch_managed(&other, Some(&launcher(ready))) + .await, + ); + assert_eq!(report.outcome, PreparationOutcome::DigestMismatch); + assert_eq!((fake.count(BEGIN), fake2.count(BEGIN)), (0, 0)); + } + + /// An inheritable descriptor at or above `at`, as another thread's creation window leaves. + fn inheritable_at(at: libc::c_int) -> OwnedFd { + let null = std::fs::File::open("/dev/null").unwrap(); + // SAFETY: F_DUPFD returns a new descriptor without close-on-exec, owned below. + let fd = unsafe { libc::fcntl(null.as_raw_fd(), libc::F_DUPFD, at) }; + assert!(fd >= at); + // SAFETY: as above. + unsafe { OwnedFd::from_raw_fd(fd) } + } + + /// Every open descriptor above 2 below 1024, as `name:` lines. + const HELD: &str = r#"n=3; while [ $n -lt 1024 ]; do if [ -e /dev/fd/$n ]; then echo "$0:$n"; fi; n=$((n + 1)); done"#; + + #[tokio::test] + async fn only_the_helper_receives_its_two_descriptors_and_the_command_none() { + let helpers = helper_directory(); + // The helper lists what it holds on entry, before it consumes the permit. A child shell + // lists them, as the stand-in's own shell keeps its script open (close-on-exec). + let entry = r#"held=$(/bin/sh -c 'n=3; while [ $n -lt 1024 ]; do if [ -e /dev/fd/$n ]; then printf " %s" $n; fi; n=$((n + 1)); done'); echo "helper:$held carriers: $permit $report""#; + let helper = stand_in(helpers.path(), "fds", entry, READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + // What another thread's creation window would leave inheritable in this process. + let unrelated = inheritable_at(700); + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let mut req = request( + &["/bin/sh", "-c", &format!("{HELD}; echo end"), "payload"], + &format!("proc-fds-{tty}"), + ); + req.tty = tty; + let launch = + launched(launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await); + let output = finished_output(&runner, &launch.process_id).await; + let mut lines = output.lines(); + let helper_line = lines.next().unwrap(); + let (held, carriers) = helper_line + .strip_prefix("helper: ") + .and_then(|rest| rest.split_once(" carriers: ")) + .unwrap_or_else(|| panic!("{output:?}")); + let mut carriers: Vec<&str> = carriers.split(' ').collect(); + carriers.sort_by_key(|fd| fd.parse::().unwrap()); + assert_eq!(held, carriers.join(" "), "tty={tty}: {output:?}"); + assert!(!held + .split(' ') + .any(|fd| fd == unrelated.as_raw_fd().to_string())); + // The command holds nothing beyond its standard descriptors. + assert_eq!(lines.collect::>(), ["end"], "tty={tty}: {output:?}"); + } + } + + #[test] + fn programs_resolve_as_the_spawn_would() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let tool = bin.join("tool"); + std::fs::write(&tool, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let data = bin.join("data"); + std::fs::write(&data, "x").unwrap(); + let cwd = dir.path(); + let path = Some("/nonexistent:bin:/bin"); + assert_eq!( + resolve_program("tool", cwd, path), + Some(cwd.join("bin").join("tool")) + ); + assert_eq!( + resolve_program("sh", cwd, path), + Some(PathBuf::from("/bin/sh")) + ); + assert_eq!(resolve_program("data", cwd, path), None); + assert_eq!( + resolve_program("bin/tool", cwd, None), + Some(cwd.join("bin/tool")) + ); + assert_eq!( + resolve_program("/bin/sh", cwd, Some("")), + Some(PathBuf::from("/bin/sh")) + ); + assert_eq!( + resolve_program("sh", cwd, None), + Some(PathBuf::from("/bin/sh")) + ); + // An empty entry is the working directory. + assert_eq!( + resolve_program("tool", &bin, Some(":/bin")), + Some(bin.join("tool")) + ); + assert_eq!(resolve_program("", cwd, path), None); + assert_eq!(resolve_program("bin", cwd, Some(".")), None); + } +} diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index 38008fe..5866fc4 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -106,6 +106,8 @@ mod apply; mod descriptors; mod files; mod fork_handlers; +#[cfg(feature = "devguard")] +pub mod launch; mod linux_sandbox; mod patch_helper; mod patch_verify; diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index 76c815d..f13f84f 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -3,6 +3,11 @@ //! standard descriptors (`descriptors`). `tty: true` uses the isolated //! `codespace-pty` adapter. When the Linux helper probe succeeds, both wrap //! the same helper argv. UDS dispatch lives in `UdsRunner`. +//! +//! A governed execution (CSRG-U4) goes through the same two spawners: their command is +//! DevGuard's launch helper with its arguments, which becomes the user's executable, and the +//! helper alone also receives its two private descriptors. Process handles, output, timeout, +//! termination and reaping are the same as for any other process. use std::collections::{HashMap, HashSet}; use std::ffi::OsString; @@ -122,6 +127,86 @@ enum SessionIo { }, } +/// What a spawn starts: the request's own command, or a governed execution's launch helper. +enum How { + Direct, + #[cfg(feature = "devguard")] + Managed(Arc), +} + +impl How { + /// The spawn has returned, whether or not it succeeded: a launch helper's descriptors are + /// closed in this process. + fn spawned(&self) { + match self { + How::Direct => {} + #[cfg(feature = "devguard")] + How::Managed(managed) => managed.close_descriptors(), + } + } +} + +/// A launch helper's invocation while it is spawned, then the owner's end of its transcript. +#[cfg(feature = "devguard")] +pub(crate) struct Managed { + invocation: std::sync::Mutex>, + transcript: std::sync::Mutex>, + helper: PathBuf, + args: Vec, + descriptors: [libc::c_int; 2], +} + +#[cfg(feature = "devguard")] +impl Managed { + fn new(invocation: codespace_devguard::launch::HelperInvocation) -> Self { + Self { + helper: invocation.helper().to_owned(), + args: invocation.args().to_vec(), + descriptors: invocation.descriptors(), + invocation: std::sync::Mutex::new(Some(invocation)), + transcript: std::sync::Mutex::new(None), + } + } + + fn helper(&self) -> &Path { + &self.helper + } + + fn args(&self) -> &[OsString] { + &self.args + } + + fn descriptors(&self) -> [libc::c_int; 2] { + self.descriptors + } + + fn make_inheritable(&self) -> std::io::Result<()> { + match self.invocation.lock().ok().as_deref() { + Some(Some(invocation)) => invocation.make_inheritable(), + _ => Err(std::io::Error::from_raw_os_error(libc::EBADF)), + } + } + + /// Close this process's copies of the two descriptors, keeping the transcript. + fn close_descriptors(&self) { + let invocation = self.invocation.lock().ok().and_then(|mut held| held.take()); + if let Some(invocation) = invocation { + if let Ok(mut transcript) = self.transcript.lock() { + *transcript = Some(invocation.into_transcript()); + } + } + } + + fn finish(&self) -> codespace_devguard::launch::Transcript { + self.close_descriptors(); + self.transcript + .lock() + .ok() + .and_then(|mut transcript| transcript.take()) + .expect("the invocation became its transcript") + } +} + struct SpawnCtx { cwd: PathBuf, cap: usize, @@ -343,6 +428,39 @@ impl InProcessRunner { } // The slot first: no process is created without one. let slot = self.reserve_slot(&req.process_id.0)?; + self.spawn_slotted(ws, req, slot, How::Direct).await + } + + /// Start a governed execution's launch helper through this runner's own pipe or PTY spawner + /// (CSRG-U4), filling the slot its preparation took. The helper alone receives the + /// invocation's two descriptors, and this process's copies are closed as soon as the spawn + /// returns. The helper becomes the execution: from here it is a process like any other. + /// Returns the spawn's result and the owner's end of the helper's transcript. + #[cfg(feature = "devguard")] + pub(crate) async fn spawn_managed( + &self, + ws: &Workspace, + req: RunnerExecRequest, + slot: SlotReservation, + invocation: codespace_devguard::launch::HelperInvocation, + ) -> ( + Result, + codespace_devguard::launch::Transcript, + ) { + let managed = Arc::new(Managed::new(invocation)); + let result = self + .spawn_slotted(ws, req, slot, How::Managed(managed.clone())) + .await; + (result, managed.finish()) + } + + async fn spawn_slotted( + &self, + ws: &Workspace, + req: RunnerExecRequest, + slot: SlotReservation, + how: How, + ) -> Result { let cap = req.output_bytes_cap.max(1) as usize; let timeout = Duration::from_millis(req.timeout_ms.max(1)); let cwd = match req.cwd { @@ -358,9 +476,9 @@ impl InProcessRunner { process_id: req.process_id.0.clone(), }; if req.tty { - self.spawn_pty(ws, req, ctx, slot).await + self.spawn_pty(ws, req, ctx, slot, how).await } else { - self.spawn_pipe(ws, req, ctx, slot) + self.spawn_pipe(ws, req, ctx, slot, how) } } @@ -370,6 +488,7 @@ impl InProcessRunner { req: RunnerExecRequest, ctx: SpawnCtx, reservation: SlotReservation, + how: How, ) -> Result { let SpawnCtx { cwd, @@ -380,9 +499,20 @@ impl InProcessRunner { lifecycle, process_id, } = ctx; - let launch = exec_launch(ws, &req)?; - let mut child = Command::new(&launch.program); - child.args(&launch.args); + let (mut child, launch) = match &how { + How::Direct => { + let launch = exec_launch(ws, &req)?; + let mut child = Command::new(&launch.program); + child.args(&launch.args); + (child, Some(launch)) + } + #[cfg(feature = "devguard")] + How::Managed(managed) => { + let mut child = Command::new(managed.helper()); + child.args(managed.args()); + (child, None) + } + }; child .current_dir(&cwd) .env_clear() @@ -390,12 +520,25 @@ impl InProcessRunner { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - crate::descriptors::exclude_unrelated(&mut child); - for (key, value) in spawn_env(&cwd, &req, launch.sandboxed) { + match &how { + How::Direct => { + crate::descriptors::exclude_unrelated(&mut child); + } + #[cfg(feature = "devguard")] + How::Managed(managed) => { + crate::descriptors::exclude_unrelated_except(&mut child, managed.descriptors()); + } + } + let sandboxed = launch.as_ref().is_some_and(|launch| launch.sandboxed); + for (key, value) in spawn_env(&cwd, &req, sandboxed) { child.env(key, value); } - let mut spawned = child.spawn().map_err(|err| { - launch.abort_plan(); + let spawned = child.spawn(); + how.spawned(); + let mut spawned = spawned.map_err(|err| { + if let Some(launch) = &launch { + launch.abort_plan(); + } ErrorBody::new( ErrorCode::ProcessSpawnFailed, format!("failed to spawn process: {err}"), @@ -490,6 +633,7 @@ impl InProcessRunner { req: RunnerExecRequest, ctx: SpawnCtx, reservation: SlotReservation, + how: How, ) -> Result { let SpawnCtx { cwd, @@ -500,29 +644,71 @@ impl InProcessRunner { lifecycle, process_id, } = ctx; - let launch = exec_launch(ws, &req)?; - let mut env = spawn_env(&cwd, &req, launch.sandboxed); + let launch = match &how { + How::Direct => Some(exec_launch(ws, &req)?), + #[cfg(feature = "devguard")] + How::Managed(_) => None, + }; + let abort = || { + if let Some(launch) = &launch { + launch.abort_plan(); + } + }; + let sandboxed = launch.as_ref().is_some_and(|launch| launch.sandboxed); + let mut env = spawn_env(&cwd, &req, sandboxed); if req.env.use_runner_defaults { env.insert("TERM".into(), "xterm".into()); } - let (program, args) = match utf8_launch(&launch.program, &launch.args) { + let utf8 = match (&launch, &how) { + (Some(launch), _) => utf8_launch(&launch.program, &launch.args), + #[cfg(feature = "devguard")] + (None, How::Managed(managed)) => utf8_launch(managed.helper(), managed.args()), + (None, _) => Err(ErrorBody::new( + ErrorCode::Internal, + "no command to launch on the PTY", + )), + }; + let (program, args) = match utf8 { Ok(value) => value, Err(err) => { - launch.abort_plan(); + abort(); return Err(err); } }; - // portable-pty forks: see `prepare_fork_spawns`. + // portable-pty and the Codex PTY's descriptor-keeping path fork: see + // `prepare_fork_spawns`. crate::prepare_fork_spawns(); - let mut session = codespace_pty::spawn(&program, &args, &cwd, &env) - .await - .map_err(|err| { - launch.abort_plan(); - ErrorBody::new( - ErrorCode::ProcessSpawnFailed, - format!("failed to spawn process: {err}"), - ) - })?; + let spawned = match &how { + How::Direct => codespace_pty::spawn(&program, &args, &cwd, &env).await, + #[cfg(feature = "devguard")] + How::Managed(managed) => { + // The pinned Codex PTY keeps only descriptors that are already inheritable, so + // the two are inheritable here from now until `how.spawned()` closes them right + // after the spawn. Every other spawner in this process excludes unrelated + // descriptors from its children meanwhile (#79). + match managed.make_inheritable() { + Ok(()) => { + codespace_pty::spawn_inheriting( + &program, + &args, + &cwd, + &env, + &managed.descriptors(), + ) + .await + } + Err(err) => Err(format!("cannot pass the launch descriptors: {err}")), + } + } + }; + how.spawned(); + let mut session = spawned.map_err(|err| { + abort(); + ErrorBody::new( + ErrorCode::ProcessSpawnFailed, + format!("failed to spawn process: {err}"), + ) + })?; let stdout = session .take_stdout() .ok_or_else(|| ErrorBody::new(ErrorCode::InvalidPatch, "PTY stdout missing"))?; From 6710f8be972460d65a0a9d9b66b43e7b2cc02172 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 15:43:34 +0900 Subject: [PATCH 2/7] gateway, worker: launch governed executions through DevGuard's helper (CSRG-U4) With --devguard register and the new --devguard-launch-helper (the absolute path of devguard-launch from the DevGuard release that serves the socket), the execution owner launches each execution of a workspace that requires resource participation once, instead of cancelling it: - InProcess: the gateway's preparer launches through its launcher. - UDS: the gateway passes the helper's path to the worker it starts. The worker states `launch` in its Hello only with a registration and a helper, and answers one `launch` request per execution, which is never cached or sent again. A lost answer is an unknown dispatch for the process_id; a request that never left is owner_unreachable. - exec_command arms the workspace lease for dispatch and returns the process_id with dispatch_status confirmed or unknown, or the refusal of a command that did not start. Without a helper, U3's admit-then-refuse stays. - workspace_info reports governs_execution true when the owner is registered and has a helper. - check_helper accepts a group write only from root's group (wheel), which only root can do. - An uncertain launch reports that the owner holds no helper once its process has ended, so an unclaimed grant does not stay charged. Validation builds devguard-launch from the DevGuard client pin with DevGuard's own lockfile (cargo install --locked --git ... --rev) and names it in CODESPACE_DEVGUARD_LAUNCH_BIN; macos-core also runs the runner's launch tests. Gateway tests launch through the fixture authority and the real helper in both modes, on pipes and PTYs, with stdin, resize, termination and status, and check D6 with concurrent launches beside ordinary children of the same gateway. Documentation: the managed launch section and its Korean counterpart, and the helper's provenance in the upstream records. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/bin/codespace-codex-runtime.rs | 34 +- crates/devguard/src/launch.rs | 39 +- crates/runner/src/admission.rs | 8 +- crates/runner/src/launch.rs | 151 ++++- crates/runner/src/process.rs | 24 + crates/runner/src/uds.rs | 70 ++ crates/runner/src/wire.rs | 204 +++++- crates/server/src/devguard.rs | 197 +++++- crates/server/src/mcp.rs | 637 +++++++++++++++++- docs/devguard-integration.md | 74 +- docs/ko/devguard-integration.md | 74 +- docs/ko/upstream-lock.md | 1 + docs/ko/upstream-update.md | 8 + docs/translations.json | 15 +- docs/upstream-lock.md | 1 + docs/upstream-update.md | 8 + scripts/tests/test_ci_plan.py | 3 +- scripts/tests/test_upstream.py | 15 +- scripts/validate-upstream.py | 33 +- 19 files changed, 1473 insertions(+), 123 deletions(-) diff --git a/crates/codex-runtime/src/bin/codespace-codex-runtime.rs b/crates/codex-runtime/src/bin/codespace-codex-runtime.rs index 1644295..d7d40c5 100644 --- a/crates/codex-runtime/src/bin/codespace-codex-runtime.rs +++ b/crates/codex-runtime/src/bin/codespace-codex-runtime.rs @@ -9,7 +9,8 @@ //! authority's socket, the consumer and its generation, and the number of a descriptor carrying //! the consumer secret, never the secret itself. The worker consumes and closes that descriptor //! before anything else, registers itself in the background and answers the gateway's -//! `Registration` requests from then on. +//! `Registration` requests from then on. With `--devguard-launch-helper` too, it launches the +//! governed executions it prepares through that helper, as their helper's parent (CSRG-U4). use std::path::PathBuf; @@ -36,7 +37,7 @@ async fn serve() { codex_process_hardening::pre_main_hardening(); // First of all, so the handed descriptor is closed before this process can start a child. #[cfg(feature = "devguard")] - let registration = devguard::start(std::env::args_os().skip(2)); + let (registration, launcher) = devguard::start(std::env::args_os().skip(2)); let socket = match std::env::args().nth(1) { Some(path) => PathBuf::from(path), None => match std::env::var_os("CODESPACE_RUNNER_SOCKET") { @@ -59,7 +60,9 @@ async fn serve() { // instead of unlinking this rendezvous. let _listener = listener; #[cfg(feature = "devguard")] - let _ = serve_runner_connection_with_registration(stream, runner, events, registration).await; + let _ = + serve_runner_connection_with_registration(stream, runner, events, registration, launcher) + .await; #[cfg(not(feature = "devguard"))] let _ = serve_runner_connection(stream, runner, events).await; } @@ -71,6 +74,7 @@ mod devguard { use std::path::PathBuf; use std::sync::Arc; + use codespace_runner::launch::Launcher; use codespace_runner::registration::{ handoff, Owner, OwnerCredential, OwnerSettings, ResourceOwner, }; @@ -83,6 +87,7 @@ mod devguard { pub(crate) consumer: Option, pub(crate) generation: Option, pub(crate) credential_fd: Option, + pub(crate) launch_helper: Option, } pub(crate) fn parse(args: impl IntoIterator) -> Result { @@ -105,17 +110,19 @@ mod devguard { .map_err(|_| format!("{flag} needs a number"))?, ) } + "--devguard-launch-helper" => parsed.launch_helper = Some(value.into()), _ => return Err(format!("unknown argument {flag}")), } } Ok(parsed) } - /// Consume the handed descriptor, then register in the background. `None` when the worker - /// was started without DevGuard settings: it then answers that its mode cannot register. + /// Consume the handed descriptor, then register in the background. No registration when + /// the worker was started without DevGuard settings: it then answers that its mode cannot + /// register. A launch helper only with a registration. pub(crate) fn start( args: impl IntoIterator, - ) -> Option> { + ) -> (Option>, Option) { let arguments = match parse(args) { Ok(arguments) => arguments, Err(problem) => { @@ -131,10 +138,18 @@ mod devguard { .unwrap_or(OwnerCredential::Unavailable), None => OwnerCredential::Unavailable, }; + let launcher = match arguments.launch_helper.map(Launcher::new) { + Some(Err(problem)) => { + eprintln!("codespace-codex-runtime: --devguard-launch-helper: {problem}"); + std::process::exit(2); + } + Some(Ok(launcher)) => Some(launcher), + None => None, + }; let (Some(socket), Some(consumer), Some(generation)) = (arguments.socket, arguments.consumer, arguments.generation) else { - return None; + return (None, None); }; let registration = OwnerRegistration::new( Owner::new( @@ -149,7 +164,7 @@ mod devguard { ); let eager = registration.clone(); tokio::spawn(async move { eager.report().await }); - Some(registration) + (Some(registration), launcher) } #[cfg(test)] @@ -171,6 +186,8 @@ mod devguard { "g1", "--devguard-credential-fd", "3", + "--devguard-launch-helper", + "/opt/devguard/bin/devguard-launch", ])) .unwrap(); assert_eq!( @@ -180,6 +197,7 @@ mod devguard { consumer: Some("codespace".into()), generation: Some("g1".into()), credential_fd: Some(3), + launch_helper: Some("/opt/devguard/bin/devguard-launch".into()), } ); assert_eq!(parse(args(&[])).unwrap(), Arguments::default()); diff --git a/crates/devguard/src/launch.rs b/crates/devguard/src/launch.rs index ecd1cf1..f2aa4d8 100644 --- a/crates/devguard/src/launch.rs +++ b/crates/devguard/src/launch.rs @@ -369,9 +369,9 @@ pub enum HelperProblem { NotAFile, /// The file is not executable. NotExecutable, - /// Another user than this one or root owns the file or a directory on its path, or the - /// group or others can write it, so they could replace the helper this owner hands its - /// permits to. + /// Another user than this one or root owns the file or a directory on its path, or others, + /// or a group other than root's, can write it, so they could replace the helper this owner + /// hands its permits to. NotPrivate, } @@ -389,15 +389,20 @@ impl HelperProblem { } /// Whether `path` can serve as the launch helper: an absolute path, through real directories -/// owned by this user or root that the group and others cannot write (apart from a root-owned -/// sticky `/tmp`), to a regular, executable file owned by this user or root that the group and -/// others cannot write. The owner hands this file each permit, so no one else may swap it. +/// owned by this user or root, to a regular, executable file owned by this user or root, where +/// only their owner or root can write any of them (apart from a root-owned sticky `/tmp`): the +/// group may write only when it is root's group (gid 0, `wheel` on macOS). The owner hands this +/// file each permit, so no one else may swap it. pub fn check_helper(path: &Path) -> Result<(), HelperProblem> { let (true, Some(parent)) = (path.is_absolute(), path.parent()) else { return Err(HelperProblem::NotAbsolute); }; let uid = effective_uid(); let owned = |meta: &std::fs::Metadata| meta.uid() == uid || meta.uid() == 0; + // Others never write it; a group does only when it is root's. + let shared = |meta: &std::fs::Metadata| { + meta.mode() & 0o002 != 0 || (meta.mode() & 0o020 != 0 && meta.gid() != 0) + }; let mut current = PathBuf::new(); for component in parent.components() { match component { @@ -409,7 +414,7 @@ pub fn check_helper(path: &Path) -> Result<(), HelperProblem> { let shared_tmp = matches!(current.to_str(), Some("/tmp" | "/private/tmp")) && meta.uid() == 0 && meta.mode() & 0o1000 != 0; - if !meta.is_dir() || !owned(&meta) || (!shared_tmp && meta.mode() & 0o022 != 0) { + if !meta.is_dir() || !owned(&meta) || (!shared_tmp && shared(&meta)) { return Err(HelperProblem::NotPrivate); } } @@ -423,7 +428,7 @@ pub fn check_helper(path: &Path) -> Result<(), HelperProblem> { if !meta.is_file() { return Err(HelperProblem::NotAFile); } - if !owned(&meta) || meta.mode() & 0o022 != 0 { + if !owned(&meta) || shared(&meta) { return Err(HelperProblem::NotPrivate); } if meta.mode() & 0o111 == 0 { @@ -621,10 +626,20 @@ pub(crate) mod tests { check_helper(&file("plain", 0o600)), Err(HelperProblem::NotExecutable) ); - assert_eq!( - check_helper(&file("group", 0o770)), - Err(HelperProblem::NotPrivate) - ); + // A group other than root's may not write it. (A file created here takes the + // directory's group, root's on macOS, so it is moved to this user's own group.) + let group = file("group", 0o770); + // SAFETY: getgid has no preconditions. + let own_group = unsafe { libc::getgid() }; + std::os::unix::fs::chown(&group, None, Some(own_group)).unwrap(); + if own_group != 0 { + assert_eq!(check_helper(&group), Err(HelperProblem::NotPrivate)); + } + // Root's group writing it is root writing it. + let wheel = file("wheel", 0o770); + if std::fs::metadata(&wheel).unwrap().gid() == 0 { + assert_eq!(check_helper(&wheel), Ok(())); + } let link = base.join("link"); std::os::unix::fs::symlink(base.join("good"), &link).unwrap(); assert_eq!(check_helper(&link), Err(HelperProblem::NotAFile)); diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index 04ea1bc..d54b7d2 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -260,8 +260,8 @@ pub enum AttemptState { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "result", rename_all = "snake_case")] pub enum PreparationOutcome { - /// Admitted and prepared; managed launch is not available yet, so the unstarted attempt - /// was cancelled. + /// Admitted and prepared, but the execution owner has no launch helper, so the unstarted + /// attempt was cancelled. LaunchUnavailable, /// No CodeSpace process slot was free; the authority was not asked. SlotUnavailable, @@ -373,8 +373,8 @@ impl ManagedPreparation { let (code, what) = match self.outcome { PreparationOutcome::LaunchUnavailable => ( ErrorCode::ManagedLaunchUnavailable, - "the resource authority admitted it, but managed launch is not available yet, \ - so the unstarted attempt was cancelled" + "the resource authority admitted it, but the execution owner has no launch \ + helper, so the unstarted attempt was cancelled" .to_string(), ), PreparationOutcome::SlotUnavailable => ( diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index 1acf93a..97adda9 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -65,6 +65,9 @@ const SETTLE_BOUND: Duration = Duration::from_secs(5); pub struct Launcher { helper: PathBuf, ready_bound: Duration, + /// Refuse when the Linux command sandbox would wrap the command. Only tests of the launch + /// flow itself turn this off. + sandbox_check: bool, } impl Launcher { @@ -78,9 +81,18 @@ impl Launcher { Ok(Self { helper, ready_bound: DEFAULT_READY_BOUND, + sandbox_check: true, }) } + /// Launch even where the Linux command sandbox is available, unsandboxed, as only a test + /// of the launch flow may. + #[cfg(test)] + pub(crate) fn ignoring_the_sandbox(mut self) -> Self { + self.sandbox_check = false; + self + } + /// Wait at most `bound` for the helper's transcript. pub fn with_ready_bound(mut self, bound: Duration) -> Self { self.ready_bound = bound; @@ -109,7 +121,7 @@ impl Launcher { } // The Linux command sandbox wraps the command in its own helper, whose process the // resource authority would govern instead; DevGuard cannot register there anyway. - if crate::linux_sandbox_available() { + if self.sandbox_check && crate::linux_sandbox_available() { return Err(PreparationOutcome::SandboxUnsupported); } if matches!(req.policy.network, NetworkAxis::Enabled) { @@ -370,14 +382,19 @@ impl PreparedExecution { Ok(Err(_)) => TranscriptEnd::Uncertain, Err(_) => { // Not settled in time: the process is CodeSpace's either way. The transcript - // is still read to its end, and a helper that never ran the executable is - // still reported once it is reaped. + // is still read to its end; then a helper that never ran the executable is + // reported once it is reaped, and one whose transcript says nothing certain + // once its process has ended. let settle = self.settler(); tokio::spawn(async move { - if let Ok(end) = ended.await { - if never_ran(end) { + match ended.await { + Ok(TranscriptEnd::Started | TranscriptEnd::ExecFailed { .. }) => {} + Ok(end) if never_ran(end) => { settle.after_reap().await; } + _ => { + settle.after_exit().await; + } } }); return self.launched(phases, LaunchDispatch::Unknown).await; @@ -394,7 +411,16 @@ impl PreparedExecution { ]); self.launched(phases, LaunchDispatch::Confirmed).await } - TranscriptEnd::Uncertain => self.launched(phases, LaunchDispatch::Unknown).await, + TranscriptEnd::Uncertain => { + // Whether the executable started is not known. Once the process has ended this + // owner holds no helper: a grant the helper never claimed is then released as + // never started, and a claimed one still settles through its scope. + let settle = self.settler(); + tokio::spawn(async move { + settle.after_exit().await; + }); + self.launched(phases, LaunchDispatch::Unknown).await + } TranscriptEnd::ExecFailed { errno } => { // The helper claimed the grant before READY, so its scope settles it; the // helper exits 126 or 127 at once. @@ -550,6 +576,23 @@ impl Settler { if !self.reaped().await { return AttemptState::Unknown; } + self.abandon_exited().await + } + + /// [`Self::after_reap`] for a process that may be the executable: however long it runs, it + /// is reported once CodeSpace has reaped it, and it is not killed for it. + async fn after_exit(&self) -> AttemptState { + loop { + match self.runner.host_process_status(&self.process_id) { + Ok(status) if status.state != ProcessState::Exited => { + tokio::time::sleep(Duration::from_millis(250)).await; + } + _ => return self.abandon_exited().await, + } + } + } + + async fn abandon_exited(&self) -> AttemptState { abandoned( call( &self.authority, @@ -663,10 +706,13 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} const READY: &str = r#"report '{"phase":"ready"}'; eval "exec $report>&-"; exec "$@""#; + /// A launcher for these tests, which test the launch flow on hosts with or without the + /// Linux command sandbox (see `the_linux_command_sandbox_is_not_launched_under_devguard`). fn launcher(helper: PathBuf) -> Launcher { Launcher::new(helper) .unwrap() .with_ready_bound(Duration::from_secs(5)) + .ignoring_the_sandbox() } async fn launch( @@ -1038,8 +1084,9 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} Some(codespace_domain::ProcessTermination::Terminated) ); - // A transcript that is not well formed: whether the command started is not known, and - // nothing is reported for the grant. + // A transcript that is not well formed: whether the command started is not known. Once + // the process has ended the owner holds no helper, which it reports: DevGuard releases + // an unclaimed grant, and settles a claimed one through its scope. let fake = Fake::admitting([prepared()]); let req = request(&["/bin/echo", "maybe"], "proc-garbage"); let launch_report = @@ -1049,7 +1096,13 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} finished_output(&runner, &launch_report.process_id).await, "maybe\n" ); - assert_eq!(fake.count(ABANDON), 0); + for _ in 0..250 { + if fake.count(ABANDON) == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(*fake.abandoned_for.lock().unwrap(), [Abandon::HelperExited]); } #[tokio::test] @@ -1156,9 +1209,13 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} async fn nothing_is_committed_when_the_owner_cannot_launch() { let helpers = helper_directory(); let ready = stand_in(helpers.path(), "ready", "", READY); + // Writable by a group other than root's (a file made here takes the directory's group, + // root's on macOS, so it is moved to this user's own group). let shared = helpers.path().join("shared"); std::fs::copy(&ready, &shared).unwrap(); std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o770)).unwrap(); + // SAFETY: getgid has no preconditions. + std::os::unix::fs::chown(&shared, None, Some(unsafe { libc::getgid() })).unwrap(); let dir = tempfile::tempdir().unwrap(); let ws = governed(dir.path()); let mut network = request(&["/bin/echo"], "proc-local"); @@ -1296,6 +1353,82 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} } } + #[tokio::test] + async fn the_linux_command_sandbox_is_not_launched_under_devguard() { + if !crate::linux_sandbox_available() { + return; + } + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let (marker, req) = marker_request(dir.path(), "proc-sandboxed"); + let launcher = Launcher::new(helper) + .unwrap() + .with_ready_bound(Duration::from_secs(5)); + let report = + not_launched(launch(&fake, &runner, &governed(dir.path()), req, Some(launcher)).await); + assert_eq!( + (report.outcome, report.attempt), + ( + PreparationOutcome::SandboxUnsupported, + AttemptState::Cancelled + ) + ); + assert_eq!(fake.count(BEGIN), 0); + assert!(!marker.exists()); + } + + /// The PTY path makes the two descriptors inheritable in this process for the length of its + /// spawn. A child another thread spawns meanwhile holds them unless its spawner excludes + /// unrelated descriptors, as CodeSpace's do: the unguarded control holds both; the guarded + /// child holds neither. + #[tokio::test] + async fn during_a_pty_spawn_only_guarded_children_are_safe() { + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let record = seen.clone(); + crate::process::inheritable_window::set( + "proc-window", + Box::new(move |descriptors| { + let script = format!( + "for n in {} {}; do if [ -e /dev/fd/$n ]; then echo held; else echo clear; fi; done", + descriptors[0], descriptors[1] + ); + let child = |guarded: bool| { + let mut command = std::process::Command::new("/bin/sh"); + command + .args(["-c", &script]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if guarded { + crate::descriptors::exclude_unrelated_std(&mut command); + } + String::from_utf8(command.output().unwrap().stdout).unwrap() + }; + record.lock().unwrap().push((child(false), child(true))); + }), + ); + let mut req = request(&["/bin/echo", "window"], "proc-window"); + req.tty = true; + let launch = launched(launch(&fake, &runner, &ws, req, Some(launcher(helper))).await); + assert_eq!( + finished_output(&runner, &launch.process_id).await, + "window\n" + ); + let seen = seen.lock().unwrap(); + assert_eq!( + seen.as_slice(), + [("held\nheld\n".to_owned(), "clear\nclear\n".to_owned())] + ); + } + #[test] fn programs_resolve_as_the_spawn_would() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index f13f84f..8a57ed9 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -127,6 +127,28 @@ enum SessionIo { }, } +/// Lets a test act inside a managed PTY spawn's window, while the launch's two descriptors are +/// inheritable in this process. +#[cfg(all(test, feature = "devguard"))] +pub(crate) mod inheritable_window { + type Hook = Box; + + static HOOK: std::sync::Mutex> = std::sync::Mutex::new(None); + + /// Run `hook` with the two descriptors inside the window of `process_id`'s spawn. + pub(crate) fn set(process_id: &str, hook: Hook) { + *HOOK.lock().unwrap() = Some((process_id.to_owned(), hook)); + } + + pub(super) fn run(process_id: &str, descriptors: [libc::c_int; 2]) { + if let Some((id, hook)) = HOOK.lock().unwrap().as_ref() { + if id == process_id { + hook(descriptors); + } + } + } +} + /// What a spawn starts: the request's own command, or a governed execution's launch helper. enum How { Direct, @@ -688,6 +710,8 @@ impl InProcessRunner { // descriptors from its children meanwhile (#79). match managed.make_inheritable() { Ok(()) => { + #[cfg(test)] + inheritable_window::run(&process_id, managed.descriptors()); codespace_pty::spawn_inheriting( &program, &args, diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs index 9a49759..928f099 100644 --- a/crates/runner/src/uds.rs +++ b/crates/runner/src/uds.rs @@ -48,6 +48,9 @@ struct Shared { /// The worker's `Hello` stated that it answers `Prepare` (CSRG-U3). #[cfg(feature = "devguard")] admission: AtomicBool, + /// The worker's `Hello` stated that it answers `Launch` (CSRG-U4). + #[cfg(feature = "devguard")] + launch: AtomicBool, } pub struct UdsRunner { @@ -104,6 +107,8 @@ impl UdsRunner { registration: AtomicBool::new(false), #[cfg(feature = "devguard")] admission: AtomicBool::new(false), + #[cfg(feature = "devguard")] + launch: AtomicBool::new(false), }); let reader_shared = shared.clone(); tokio::spawn(async move { @@ -122,6 +127,7 @@ impl UdsRunner { if let RunnerOpResult::Hello { registration, admission, + launch, .. } = hello { @@ -129,6 +135,7 @@ impl UdsRunner { .registration .store(registration, Ordering::SeqCst); self.shared.admission.store(admission, Ordering::SeqCst); + self.shared.launch.store(launch, Ordering::SeqCst); } #[cfg(not(feature = "devguard"))] let _ = hello; @@ -238,6 +245,69 @@ impl UdsRunner { } } + /// Whether the worker's `Hello` stated that it answers `Launch` (CSRG-U4). + #[cfg(feature = "devguard")] + pub fn states_launch(&self) -> bool { + self.shared.launch.load(Ordering::SeqCst) + } + + /// Have the worker prepare a governed execution under its own registration and launch it + /// once through its own launch helper (CSRG-U4). The request is never sent again. When the + /// request never left, nothing was asked. When no report comes back, the worker may have + /// launched it: the report is a launch whose dispatch is unknown, for its `process_id`. + #[cfg(feature = "devguard")] + pub async fn launch( + &self, + ws: &Workspace, + request: RunnerExecRequest, + attempt_id: String, + ) -> Result { + use crate::admission::{AttemptState, ManagedPreparation, PreparationOutcome}; + use crate::launch::{LaunchDispatch, LaunchReport, ManagedLaunch}; + use codespace_domain::ResourceRegistrationState; + if !self.states_launch() { + return Err(RunnerError::execution(ErrorBody::new( + ErrorCode::ResourcePolicyUnsupported, + "this worker cannot launch governed executions", + ))); + } + let process_id = request.process_id.clone(); + match self + .call(RunnerOp::Launch { + workspace: ws.clone(), + request, + attempt_id: attempt_id.clone(), + }) + .await + { + Ok(RunnerOpResult::Launched(report)) => Ok(report), + Ok(other) => Err(unexpected(other)), + Err(RunnerError::TransportBeforeDispatch { .. }) => { + Ok(LaunchReport::NotLaunched(ManagedPreparation { + attempt_id, + process_id, + outcome: PreparationOutcome::AuthorityUnavailable { + registration: ResourceRegistrationState::OwnerUnreachable, + error_code: None, + }, + attempt: AttemptState::NotAsked, + account: None, + })) + } + Err(RunnerError::TransportAmbiguous { .. }) => { + Ok(LaunchReport::Launched(ManagedLaunch { + attempt_id, + process_id, + dispatch: LaunchDispatch::Unknown, + phases: Vec::new(), + account: None, + scope_root_pid: None, + })) + } + Err(err) => Err(err), + } + } + pub async fn close(&self) { self.shared.alive.store(false, Ordering::SeqCst); { diff --git a/crates/runner/src/wire.rs b/crates/runner/src/wire.rs index 904887c..10f7960 100644 --- a/crates/runner/src/wire.rs +++ b/crates/runner/src/wire.rs @@ -171,15 +171,25 @@ pub enum RunnerOp { #[cfg(feature = "devguard")] Registration, /// Prepare a governed execution under the worker's own registration (CSRG-U3): its slot, - /// its admission and, as managed launch is not available yet, the cancellation of the - /// unstarted attempt. Sent only to a worker whose `Hello` states `admission`. The worker - /// never starts the command. + /// its admission and, as the worker cannot launch it, the cancellation of the unstarted + /// attempt. Sent only to a worker whose `Hello` states `admission`. The worker never starts + /// the command. #[cfg(feature = "devguard")] Prepare { workspace: Workspace, request: RunnerExecRequest, attempt_id: String, }, + /// Prepare a governed execution under the worker's own registration and launch it once + /// through the worker's launch helper (CSRG-U4): the worker is the helper's parent, as + /// DevGuard requires of the registered owner. Sent only to a worker whose `Hello` states + /// `launch`, and never sent again: a launch is not replayed. + #[cfg(feature = "devguard")] + Launch { + workspace: Workspace, + request: RunnerExecRequest, + attempt_id: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -208,11 +218,18 @@ pub enum RunnerOpResult { #[cfg(feature = "devguard")] #[serde(default, skip_serializing_if = "is_false")] admission: bool, + /// The worker answers `Launch`: it registers and has a launch helper (CSRG-U4). Left + /// out when false, as `registration`. + #[cfg(feature = "devguard")] + #[serde(default, skip_serializing_if = "is_false")] + launch: bool, }, #[cfg(feature = "devguard")] Registration(codespace_domain::ResourceAuthorityInfo), #[cfg(feature = "devguard")] Prepared(crate::admission::ManagedPreparation), + #[cfg(feature = "devguard")] + Launched(crate::launch::LaunchReport), } #[cfg(feature = "devguard")] @@ -220,12 +237,24 @@ fn is_false(value: &bool) -> bool { !*value } -/// The worker's registration, when it was started with one. +/// The worker's registration, when it was started with one, and its launch helper. #[cfg(feature = "devguard")] -type Registrar = Option>; +#[derive(Clone, Default)] +struct Registrar { + registration: Option>, + launcher: Option, +} #[cfg(not(feature = "devguard"))] type Registrar = (); +#[cfg(feature = "devguard")] +impl Registrar { + /// The worker launches governed executions: it registers and has a launch helper. + fn launches(&self) -> bool { + self.registration.is_some() && self.launcher.is_some() + } +} + /// Host worker: `InProcessRunner` plus a `ProcessExited` event stream. pub fn host_worker() -> (InProcessRunner, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); @@ -246,26 +275,37 @@ where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { #[cfg(feature = "devguard")] - let registrar = None; + let registrar = Registrar::default(); #[cfg(not(feature = "devguard"))] let registrar = (); serve(stream, runner, events, registrar).await } /// [`serve_runner_connection`] for a worker that owns its executions' DevGuard registration. -/// A `Registration` request runs beside the other requests, so no process query, write, -/// resize or termination waits for its session. +/// A `Registration`, `Prepare` or `Launch` request runs beside the other requests, so no +/// process query, write, resize or termination waits for its sessions. With `launcher` the +/// worker launches governed executions (CSRG-U4). #[cfg(feature = "devguard")] pub async fn serve_runner_connection_with_registration( stream: S, runner: InProcessRunner, events: mpsc::UnboundedReceiver, registration: Option>, + launcher: Option, ) -> Result<(), std::io::Error> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - serve(stream, runner, events, registration).await + serve( + stream, + runner, + events, + Registrar { + registration, + launcher, + }, + ) + .await } async fn serve( @@ -333,7 +373,7 @@ where // Not cached for replay: asking again opens a new session for the same identity. let (write, registrar) = (write.clone(), registrar.clone()); tokio::spawn(async move { - let result = match registrar { + let result = match registrar.registration { Some(registration) => { Ok(RunnerOpResult::Registration(registration.report().await)) } @@ -358,7 +398,7 @@ where // Beside the other requests, and never cached: a preparation is not replayed. let (write, registrar, runner) = (write.clone(), registrar.clone(), runner.clone()); tokio::spawn(async move { - let result = match registrar { + let result = match registrar.registration { Some(registration) => Ok(RunnerOpResult::Prepared( crate::admission::Preparer::new(registration.owner(), runner) .prepare_and_dispose(workspace, exec, attempt_id) @@ -375,6 +415,35 @@ where }); continue; } + #[cfg(feature = "devguard")] + if let Some(RunnerOp::Launch { + workspace, + request: exec, + attempt_id, + }) = request.op + { + // Beside the other requests, and never cached: a launch is never replayed. The + // launch is its own task, so it settles even if the gateway stops waiting. + let (write, registrar, runner) = (write.clone(), registrar.clone(), runner.clone()); + tokio::spawn(async move { + let result = match (registrar.registration, registrar.launcher) { + (Some(registration), Some(launcher)) => Ok(RunnerOpResult::Launched( + crate::admission::Preparer::new(registration.owner(), runner) + .with_launcher(Some(launcher)) + .prepare_and_launch(workspace, exec, attempt_id) + .await, + )), + _ => Err(ErrorBody::new( + ErrorCode::ResourcePolicyUnsupported, + "this worker was started without DevGuard settings or a launch helper", + )), + }; + let response = WireEnvelope::response(request_id, result); + let mut writer = write.lock().await; + let _ = write_frame(&mut *writer, &response).await; + }); + continue; + } let response = match request.op { Some(RunnerOp::Replay { request_id: original, @@ -397,6 +466,22 @@ where }), Some(op) => { let dispatched = dispatch(&runner, op).await; + // A worker states `launch` only with a registration and a launch helper. + #[cfg(feature = "devguard")] + let dispatched = dispatched.map(|result| match result { + RunnerOpResult::Hello { + protocol, + registration, + admission, + .. + } => RunnerOpResult::Hello { + protocol, + registration, + admission, + launch: registrar.launches(), + }, + other => other, + }); let envelope = WireEnvelope::response(request_id.clone(), dispatched); if !request_id.is_empty() { if cache.len() >= MAX_REPLAY { @@ -519,12 +604,16 @@ async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result unreachable!("replay is handled before dispatch"), #[cfg(feature = "devguard")] RunnerOp::Registration => unreachable!("registration is handled before dispatch"), #[cfg(feature = "devguard")] RunnerOp::Prepare { .. } => unreachable!("preparation is handled before dispatch"), + #[cfg(feature = "devguard")] + RunnerOp::Launch { .. } => unreachable!("launch is handled before dispatch"), }; result.map_err(RunnerError::into_error_body) } @@ -745,13 +834,29 @@ mod tests { ) -> ( tokio::net::unix::OwnedReadHalf, tokio::net::unix::OwnedWriteHalf, + ) { + serve_launching(registration, None) + } + + fn serve_launching( + registration: Option>, + launcher: Option, + ) -> ( + tokio::net::unix::OwnedReadHalf, + tokio::net::unix::OwnedWriteHalf, ) { let (client, server) = tokio::net::UnixStream::pair().unwrap(); let (worker, events) = host_worker(); tokio::spawn(async move { - serve_runner_connection_with_registration(server, worker, events, registration) - .await - .expect("serve"); + serve_runner_connection_with_registration( + server, + worker, + events, + registration, + launcher, + ) + .await + .expect("serve"); }); client.into_split() } @@ -765,9 +870,10 @@ mod tests { protocol, registration, admission, + launch, }) => assert_eq!( - (protocol, registration, admission), - (WIRE_PROTOCOL, true, true) + (protocol, registration, admission, launch), + (WIRE_PROTOCOL, true, true, false) ), other => panic!("unexpected {other:?}"), } @@ -801,6 +907,37 @@ mod tests { ErrorCode::ResourcePolicyUnsupported ); assert!(!dir.path().join("started").exists()); + // Nor launch one (CSRG-U4). + let mut workspace = Workspace::new( + codespace_domain::WorkspaceId("gov".into()), + dir.path().to_path_buf(), + codespace_domain::Profile::WorkspaceWrite, + ); + workspace.resources.participation = codespace_policy::Participation::Required; + request( + &mut write, + "rrpc-launch", + RunnerOp::Launch { + workspace, + request: RunnerExecRequest::for_host( + vec![ + "/usr/bin/touch".into(), + dir.path().join("started").display().to_string(), + ], + ProcessId("proc-gov".into()), + codespace_domain::Profile::WorkspaceWrite, + ), + attempt_id: "cs-attempt-2".into(), + }, + ) + .await; + let reply = response(&mut read).await; + assert_eq!(reply.request_id.as_deref(), Some("rrpc-launch")); + assert_eq!( + reply.error.unwrap().code, + ErrorCode::ResourcePolicyUnsupported + ); + assert!(!dir.path().join("started").exists()); request(&mut write, "rrpc-reg", RunnerOp::Registration).await; let reply = response(&mut read).await; assert_eq!(reply.request_id.as_deref(), Some("rrpc-reg")); @@ -823,11 +960,45 @@ mod tests { RunnerOpResult::Hello { registration: false, admission: false, + launch: false, .. } )); } + /// A worker states `launch` only when it registers and has a launch helper (CSRG-U4). + #[tokio::test] + async fn only_a_registered_worker_with_a_launch_helper_states_launch() { + let dir = private_directory(); + let owner = || { + OwnerRegistration::new( + Owner::new( + OwnerSettings { + socket: dir.path().join("absent.sock"), + consumer: "codespace".into(), + generation: "g1".into(), + }, + OwnerCredential::Unavailable, + ), + ResourceOwner::Worker, + ) + }; + let launcher = + || crate::launch::Launcher::new("/usr/libexec/devguard-launch".into()).ok(); + for (registration, launcher, expected) in [ + (Some(owner()), launcher(), true), + (Some(owner()), None, false), + (None, launcher(), false), + ] { + let (mut read, mut write) = serve_launching(registration, launcher); + request(&mut write, "rrpc-hello", RunnerOp::Hello).await; + match response(&mut read).await.result { + Some(RunnerOpResult::Hello { launch, .. }) => assert_eq!(launch, expected), + other => panic!("unexpected {other:?}"), + } + } + } + /// A private directory on a path without symbolic links, as the credential needs. fn private_directory() -> tempfile::TempDir { tempfile::Builder::new() @@ -981,6 +1152,7 @@ mod tests { worker, events, Some(registration), + None, ) .await .expect("serve"); diff --git a/crates/server/src/devguard.rs b/crates/server/src/devguard.rs index 427ef32..8f065f1 100644 --- a/crates/server/src/devguard.rs +++ b/crates/server/src/devguard.rs @@ -9,8 +9,10 @@ //! registers itself with DevGuard and `workspace_info` reports that registration: the gateway //! itself in InProcess mode, the worker it starts in UDS mode, which gets the consumer secret //! through one private descriptor. The gateway never registers on the worker's behalf, and a -//! worker it did not start cannot register. Nothing is admitted or launched through DevGuard, -//! and no process query, termination or timeout waits for a registration. +//! worker it did not start cannot register. That owner admits each execution of a workspace +//! that requires resource participation (CSRG-U3) and, with `--devguard-launch-helper`, +//! launches it once through DevGuard's launch helper as the helper's parent (CSRG-U4). No +//! process query, termination or timeout waits for a DevGuard session. //! //! The consumer secret stays in its file until a session or the worker's start needs it. These //! settings hold only its path, and only states, DevGuard's error codes and the owner's kind and @@ -27,6 +29,7 @@ use codespace_domain::{ ResourceRegistrationState, }; use codespace_runner::admission::{ManagedPreparation, Preparer}; +use codespace_runner::launch::{LaunchReport, Launcher}; use codespace_runner::registration::{ handoff, status_info, Owner, OwnerCredential, OwnerRegistration, OwnerSettings, }; @@ -43,8 +46,9 @@ pub enum DevGuardMode { Off, /// Report DevGuard's status in `workspace_info`; nothing is registered, admitted or launched. Status, - /// The execution owner registers with DevGuard and `workspace_info` reports it; nothing is - /// admitted or launched. + /// The execution owner registers with DevGuard and `workspace_info` reports it. Executions + /// of workspaces that require resource participation are admitted, and with a launch helper + /// launched, through it. Register, } @@ -96,6 +100,27 @@ pub struct DevGuardArgs { required_if_eq_any([("devguard", "status"), ("devguard", "register")]) )] pub credential_file: Option, + + /// The absolute path of DevGuard's launch helper, `devguard-launch`, of the release serving + /// the socket. With `register`, the execution owner launches the executions of workspaces + /// that require resource participation through it; without it they are admitted and then + /// refused. + #[arg( + id = "devguard_launch_helper", + long = "devguard-launch-helper", + env = "CODESPACE_DEVGUARD_LAUNCH_HELPER", + value_parser = absolute_path + )] + pub launch_helper: Option, +} + +fn absolute_path(value: &str) -> Result { + let path = PathBuf::from(value); + if path.is_absolute() { + Ok(path) + } else { + Err("must be an absolute path".into()) + } } impl DevGuardArgs { @@ -115,7 +140,21 @@ impl DevGuardArgs { /// What a UDS worker the gateway starts needs to register itself, with `register`. pub fn worker(&self) -> Option { match self.mode { - DevGuardMode::Register => self.settings().map(|settings| WorkerSettings { settings }), + DevGuardMode::Register => self.settings().map(|settings| WorkerSettings { + settings, + launch_helper: self.launch_helper.clone(), + }), + DevGuardMode::Off | DevGuardMode::Status => None, + } + } + + /// The launch helper the execution owner launches through, with `register` (CSRG-U4). + pub fn launcher(&self) -> Option { + match self.mode { + DevGuardMode::Register => self + .launch_helper + .clone() + .and_then(|helper| Launcher::new(helper).ok()), DevGuardMode::Off | DevGuardMode::Status => None, } } @@ -175,8 +214,9 @@ impl ResourceAuthority { /// Which process owns CodeSpace's executions, and so registers (CSRG-U2). pub(crate) enum ExecutionOwner { - /// The gateway runs executions in its own process and registers itself. - InProcess(Arc), + /// The gateway runs executions in its own process and registers itself; with a launch + /// helper it launches governed executions (CSRG-U4). + InProcess(Arc, Option), /// The worker the gateway started owns them, registers itself and reports to the gateway. Worker, /// A worker the gateway did not start: nothing can hand it the consumer secret. @@ -186,7 +226,7 @@ pub(crate) enum ExecutionOwner { impl ExecutionOwner { fn kind(&self) -> ResourceOwner { match self { - Self::InProcess(_) => ResourceOwner::InProcess, + Self::InProcess(..) => ResourceOwner::InProcess, Self::Worker | Self::UnstartedWorker => ResourceOwner::Worker, } } @@ -203,20 +243,29 @@ pub struct Registration { impl Registration { /// The registration for the gateway's runner mode. In InProcess mode the gateway reads - /// the consumer secret from its file for each session, as a status probe does. - pub fn new(settings: Settings, runner: RunnerMode, starts_worker: bool) -> Arc { + /// the consumer secret from its file for each session, as a status probe does, and + /// launches through `launcher`; a worker the gateway starts gets its own. + pub fn new( + settings: Settings, + runner: RunnerMode, + starts_worker: bool, + launcher: Option, + ) -> Arc { let owner = match (runner, starts_worker) { - (RunnerMode::InProcess, _) => ExecutionOwner::InProcess(OwnerRegistration::new( - Owner::new( - OwnerSettings { - socket: settings.socket.clone(), - consumer: settings.consumer.clone(), - generation: settings.generation.clone(), - }, - OwnerCredential::File(settings.credential_file.clone()), + (RunnerMode::InProcess, _) => ExecutionOwner::InProcess( + OwnerRegistration::new( + Owner::new( + OwnerSettings { + socket: settings.socket.clone(), + consumer: settings.consumer.clone(), + generation: settings.generation.clone(), + }, + OwnerCredential::File(settings.credential_file.clone()), + ), + ResourceOwner::InProcess, ), - ResourceOwner::InProcess, - )), + launcher, + ), (RunnerMode::Uds, true) => ExecutionOwner::Worker, (RunnerMode::Uds, false) => ExecutionOwner::UnstartedWorker, }; @@ -236,12 +285,76 @@ impl Registration { /// worker states it. pub(crate) fn prepares(&self, runner: &RuntimeBackend) -> bool { match (&self.owner, runner) { - (ExecutionOwner::InProcess(_), RuntimeBackend::InProcess(_)) => true, + (ExecutionOwner::InProcess(..), RuntimeBackend::InProcess(_)) => true, (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker.states_admission(), _ => false, } } + /// Whether this gateway's execution owner launches governed executions with `runner` + /// (CSRG-U4): the gateway with a launch helper in InProcess mode, or the worker it started + /// when that worker states it. + pub(crate) fn launches(&self, runner: &RuntimeBackend) -> bool { + match (&self.owner, runner) { + (ExecutionOwner::InProcess(_, launcher), RuntimeBackend::InProcess(_)) => { + launcher.is_some() + } + (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker.states_launch(), + _ => false, + } + } + + /// Have the execution owner prepare `request` as attempt `attempt_id` and launch it once + /// (CSRG-U4), and report how it ended. Only for an owner that [`Self::launches`]. + pub(crate) async fn launch( + &self, + runner: &RuntimeBackend, + ws: &codespace_policy::Workspace, + request: codespace_runner::RunnerExecRequest, + attempt_id: String, + ) -> Result { + let report = match (&self.owner, runner) { + ( + ExecutionOwner::InProcess(registration, launcher), + RuntimeBackend::InProcess(runner), + ) if launcher.is_some() => { + Preparer::new(registration.owner(), runner.clone()) + .with_launcher(launcher.clone()) + .prepare_and_launch(ws.clone(), request, attempt_id) + .await + } + (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker + .launch(ws, request, attempt_id) + .await + .map_err(codespace_runner::RunnerError::into_error_body)?, + _ => { + return Err(codespace_domain::ErrorBody::new( + codespace_domain::ErrorCode::ResourcePolicyUnsupported, + "this runner mode cannot launch governed executions", + )) + } + }; + match &report { + LaunchReport::Launched(launch) => tracing::info!( + attempt_id = %launch.attempt_id, + process_id = %launch.process_id.0, + dispatch = ?launch.dispatch, + phases = ?launch.phases, + applied = ?launch.account.map(|account| account.applied), + scope_root_pid = ?launch.scope_root_pid, + "DevGuard managed launch" + ), + LaunchReport::NotLaunched(report) => tracing::info!( + attempt_id = %report.attempt_id, + process_id = %report.process_id.0, + outcome = ?report.outcome, + attempt = ?report.attempt, + "DevGuard managed launch; the command was not started" + ), + } + Ok(report) + } + /// Have the execution owner prepare `request` as attempt `attempt_id` and report how it /// ended (CSRG-U3). Nothing is started. Only for an owner that [`Self::prepares`]. pub(crate) async fn prepare( @@ -252,7 +365,7 @@ impl Registration { attempt_id: String, ) -> Result { let report = match (&self.owner, runner) { - (ExecutionOwner::InProcess(registration), RuntimeBackend::InProcess(runner)) => { + (ExecutionOwner::InProcess(registration, _), RuntimeBackend::InProcess(runner)) => { Preparer::new(registration.owner(), runner.clone()) .prepare_and_dispose(ws.clone(), request, attempt_id) .await @@ -282,7 +395,7 @@ impl Registration { /// the owner could not be asked beside the authority's status from the gateway's own probe. pub async fn report(&self, runner: &RuntimeBackend) -> ResourceAuthorityInfo { let reported = match (&self.owner, runner) { - (ExecutionOwner::InProcess(registration), _) => Ok(registration.report().await), + (ExecutionOwner::InProcess(registration, _), _) => Ok(registration.report().await), (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker.registration().await, (ExecutionOwner::Worker, RuntimeBackend::InProcess(_)) | (ExecutionOwner::UnstartedWorker, _) => { @@ -290,7 +403,15 @@ impl Registration { } }; let info = match reported { - Ok(info) => info, + // A registered owner that launches governs the executions of workspaces that + // require resource participation (CSRG-U4). + Ok(mut info) => { + info.governs_execution = self.launches(runner) + && info.registration.as_ref().is_some_and(|registration| { + registration.state == ResourceRegistrationState::Registered + }); + info + } Err(state) => { let mut info = self.status.status().await; info.participation = ResourceParticipation::Registration; @@ -326,17 +447,21 @@ impl Registration { } } -/// What a worker the gateway starts gets so it registers itself (CSRG-U2). None of it is a -/// secret: the consumer secret goes through one private descriptor. +/// What a worker the gateway starts gets so it registers itself (CSRG-U2), and launches +/// (CSRG-U4). None of it is a secret: the consumer secret goes through one private descriptor. #[derive(Debug, Clone)] pub struct WorkerSettings { settings: Settings, + launch_helper: Option, } impl WorkerSettings { #[cfg(test)] pub(crate) fn from_settings(settings: Settings) -> Self { - Self { settings } + Self { + settings, + launch_helper: None, + } } /// Add the worker's DevGuard arguments and hand it the consumer secret, read from its file @@ -352,6 +477,9 @@ impl WorkerSettings { .arg(&self.settings.consumer) .arg("--devguard-generation") .arg(&self.settings.generation); + if let Some(helper) = &self.launch_helper { + command.arg("--devguard-launch-helper").arg(helper); + } if let Some(carrier) = handoff::prepare(&self.settings.credential_file) { let fd = carrier.attach(command.as_std_mut()); command.arg("--devguard-credential-fd").arg(fd.to_string()); @@ -570,7 +698,7 @@ pub(crate) mod tests { (RunnerMode::Uds, true, ResourceOwner::Worker), (RunnerMode::Uds, false, ResourceOwner::Worker), ] { - let registration = Registration::new(settings.clone(), runner, starts_worker); + let registration = Registration::new(settings.clone(), runner, starts_worker, None); assert_eq!(registration.owner(), owner); } } @@ -583,7 +711,8 @@ pub(crate) mod tests { .get_arguments() .filter(|arg| arg.get_id().as_str().starts_with("devguard")) .collect(); - assert_eq!(devguard.len(), 5); + // The socket, consumer, generation, credential file and launch helper, and the mode. + assert_eq!(devguard.len(), 6); for arg in devguard { let long = arg.get_long().unwrap(); assert!( @@ -952,7 +1081,7 @@ pub(crate) mod tests { } /// A connected socket left inheritable, as one is inside DevGuard's connect window on macOS. - fn inheritable_socket() -> ( + pub(crate) fn inheritable_socket() -> ( std::os::unix::net::UnixStream, std::os::unix::net::UnixStream, ) { @@ -1544,7 +1673,7 @@ pub(crate) mod tests { let in_process = RuntimeBackend::in_process(Arc::new(|_| {})); // The gateway registers itself when it runs the executions... - let registration = Registration::new(settings.clone(), RunnerMode::InProcess, false); + let registration = Registration::new(settings.clone(), RunnerMode::InProcess, false, None); let info = past_the_credential(stopped_at_the_credential, || { registration.report(&in_process) }) @@ -1566,7 +1695,8 @@ pub(crate) mod tests { // ...but never for a worker: one it did not start reports that it cannot register, // beside the authority's status from the gateway's own probe. for starts_worker in [false, true] { - let registration = Registration::new(settings.clone(), RunnerMode::Uds, starts_worker); + let registration = + Registration::new(settings.clone(), RunnerMode::Uds, starts_worker, None); let info = past_the_credential(stopped_at_the_credential, || { registration.report(&in_process) }) @@ -1621,7 +1751,7 @@ pub(crate) mod tests { let settings = settings(dir.path(), dir.path().join("absent.sock")); let in_process = RuntimeBackend::in_process(Arc::new(|_| {})); for (runner, starts_worker) in [(RunnerMode::InProcess, false), (RunnerMode::Uds, false)] { - let registration = Registration::new(settings.clone(), runner, starts_worker); + let registration = Registration::new(settings.clone(), runner, starts_worker, None); past_the_credential(stopped_at_the_credential, || { registration.report(&in_process) }) @@ -1664,6 +1794,7 @@ pub(crate) mod tests { protocol: WIRE_PROTOCOL, registration, admission: registration, + launch: false, }; let reply = WireEnvelope::response(request.request_id.unwrap(), Ok(hello)); write_frame(&mut server, &reply).await.unwrap(); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index eb3cd88..e7d6afb 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -882,11 +882,42 @@ impl CodeSpace { Participating::Off => {} #[cfg(feature = "devguard")] Participating::Managed => { - // The workspace lease is held while the owner prepares; nothing is started, so - // it is released with the answer whatever the attempt's own state. - let refused = self.prepare_governed(ws, req).await; - reservation.abort(); - return Err(refused); + if !self + .registration + .as_ref() + .is_some_and(|registration| registration.launches(&self.runner)) + { + // The workspace lease is held while the owner prepares; nothing is started, + // so it is released with the answer whatever the attempt's own state. + let refused = self.prepare_governed(ws, req).await; + reservation.abort(); + return Err(refused); + } + // The owner may create a process from here on (CSRG-U4). + reservation.arm_dispatch(); + return match self.launch_governed(ws, req).await { + Ok(launch) => { + reservation.confirm(); + Ok(ExecCommandResult { + process_id: launch.process_id, + dispatch_status: match launch.dispatch { + codespace_runner::launch::LaunchDispatch::Confirmed => { + ExecDispatchStatus::Confirmed + } + codespace_runner::launch::LaunchDispatch::Unknown => { + ExecDispatchStatus::Unknown + } + }, + coordination: self + .hint(¶ms.workspace_id.0, params.work_id.as_ref()), + }) + } + Err(refused) => { + // The command did not start; a helper that existed was reaped. + reservation.abort(); + Err(refused) + } + }; } } reservation.arm_dispatch(); @@ -951,8 +982,8 @@ impl CodeSpace { )) } - /// Have the execution owner prepare `req` (CSRG-U3). Managed launch is not available yet, - /// so every preparation ends in a refusal and nothing is started. + /// Have the execution owner prepare `req` without launching it (CSRG-U3): an owner that + /// cannot launch cancels each preparation, so it ends in a refusal and nothing is started. #[cfg(feature = "devguard")] async fn prepare_governed(&self, ws: &Workspace, req: RunnerExecRequest) -> ErrorBody { let Some(registration) = &self.registration else { @@ -968,6 +999,32 @@ impl CodeSpace { } } + /// Have the execution owner prepare `req` and launch it once under the resource authority + /// (CSRG-U4): the launched process, or the refusal of a command that did not start. + #[cfg(feature = "devguard")] + async fn launch_governed( + &self, + ws: &Workspace, + req: RunnerExecRequest, + ) -> Result { + let Some(registration) = &self.registration else { + return Err(ErrorBody::new( + ErrorCode::Internal, + "no execution owner registration", + )); + }; + let attempt_id = format!("cs-attempt-{}", Uuid::new_v4().simple()); + match registration + .launch(&self.runner, ws, req, attempt_id) + .await? + { + codespace_runner::launch::LaunchReport::Launched(launch) => Ok(launch), + codespace_runner::launch::LaunchReport::NotLaunched(report) => { + Err(report.into_error_body(&ws.id.0)) + } + } + } + #[cfg(not(feature = "devguard"))] async fn participation_readiness(&self) -> String { "this CodeSpace was built without DevGuard".into() @@ -1103,15 +1160,29 @@ impl CodeSpace { #[cfg(feature = "devguard")] if let Some(settings) = cli.devguard.settings() { if cli.devguard.mode == crate::devguard::DevGuardMode::Register { + let launcher = cli.devguard.launcher(); + if let Some(launcher) = &launcher { + // Checked again before each launch; a helper that cannot be used refuses + // the launch, never runs anything else. + if let Err(problem) = launcher.usable() { + tracing::warn!( + problem = problem.describe(), + "DevGuard launch helper cannot be used now" + ); + } + } let registration = crate::devguard::Registration::new( settings, cli.runner, cli.runtime_bin.is_some(), + launcher, ); tracing::info!( participation = "registration", owner = ?registration.owner(), - "DevGuard registration of the execution owner enabled; it governs no execution" + launch_helper = cli.devguard.launch_helper.is_some(), + "DevGuard registration of the execution owner enabled; with a launch helper \ + it launches the executions of workspaces that require resource participation" ); return handler.with_registration(registration); } @@ -2551,7 +2622,7 @@ mod devguard_tests { } /// With native evidence DevGuard admits each governed execution and CodeSpace cancels it, - /// as managed launch is not available; without it the owner cannot register. + /// as this owner has no launch helper; without it the owner cannot register. async fn assert_governed( fixture: &mut FixtureAuthority, handler: &CodeSpace, @@ -2562,7 +2633,7 @@ mod devguard_tests { ( ErrorCode::ManagedLaunchUnavailable, &[ - "the resource authority admitted it, but managed launch is not available yet", + "the resource authority admitted it, but the execution owner has no launch helper", ": cancelled; nothing was started", ], ) @@ -2684,6 +2755,7 @@ mod devguard_tests { protocol: WIRE_PROTOCOL, registration: true, admission: true, + launch: false, }; let reply = WireEnvelope::response(request.request_id.unwrap(), Ok(hello)); write_frame(&mut worker, &reply).await.unwrap(); @@ -2709,6 +2781,7 @@ mod devguard_tests { settings(dir.path(), dir.path().join("absent.sock")), crate::config::RunnerMode::Uds, true, + None, )); let touch = format!("{}/started", dir.path().display()); let raw = handler @@ -2927,4 +3000,548 @@ mod devguard_tests { ); process.wait_exit().await; } + + // CSRG-U4: governed executions launched once through DevGuard's launch helper. + + /// DevGuard's launch helper built from the pinned source (`CODESPACE_DEVGUARD_LAUNCH_BIN`). + fn launch_helper() -> Option { + crate::devguard::tests::test_binary("CODESPACE_DEVGUARD_LAUNCH_BIN") + } + + /// `gov` and `gov-confirm` require resource participation, asking little enough for a small + /// host's work capacity (under 500 m CPU on a three-core CI runner), beside `demo`. + fn launch_registry(root: &std::path::Path) -> Registry { + launch_registry_with(root, 0) + } + + /// [`launch_registry`], plus `gov-0`... and `demo-0`... up to `each`: a workspace runs one + /// live process at a time, so concurrent children need workspaces of their own. + fn launch_registry_with(root: &std::path::Path, each: usize) -> Registry { + let governed = |approvals: &str| { + serde_json::json!({ + "root": root, + "profile": "workspace-write", + "approvals": approvals, + "resources": {"participation": "required", "request": { + "cpu_milli": 50, "memory_bytes": 33554432, "tasks": 4}}, + }) + }; + let plain = serde_json::json!({"root": root, "profile": "workspace-write"}); + let mut workspaces = serde_json::Map::new(); + workspaces.insert("demo".into(), plain.clone()); + workspaces.insert("gov".into(), governed("off")); + workspaces.insert("gov-confirm".into(), governed("confirm")); + for n in 0..each { + workspaces.insert(format!("gov-{n}"), governed("off")); + workspaces.insert(format!("demo-{n}"), plain.clone()); + } + let config = serde_json::json!({ "workspaces": workspaces }); + Registry::load_json(&config.to_string()).unwrap() + } + + /// The process's whole output once it ended, and its final status. + async fn finished( + handler: &CodeSpace, + process_id: &ProcessId, + ) -> (String, ProcessStatusResult) { + let mut output = String::new(); + for _ in 0..1000 { + let read = handler + .read_process(Parameters(ReadProcessParams { + process_id: process_id.clone(), + cursor: 0, + })) + .await + .unwrap() + .0; + output = read.chunk.replace("\r\n", "\n"); + if read.eof { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + for _ in 0..500 { + let status = handler + .process_status(Parameters(ProcessStatusParams { + process_id: process_id.clone(), + })) + .await + .unwrap() + .0; + if status.state == ProcessState::Exited { + return (output, status); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("{process_id:?} did not exit: {output:?}"); + } + + /// Wait until DevGuard holds nothing for CodeSpace: it releases a launched attempt once its + /// reconciler observes the scope's end, within about a second. + fn released(fixture: &mut FixtureAuthority) -> (Vec, u64) { + let mut charged = fixture.charged(); + for _ in 0..100 { + if charged == (Vec::new(), 0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + charged = fixture.charged(); + } + charged + } + + /// `exec_command`, again while it stops at the credential (see `past_the_credential`). + async fn executed( + handler: &CodeSpace, + params: ExecCommandParams, + ) -> Result { + past_the_credential( + |result: &Result| { + matches!(result, Err(body) + if body.message.contains("(registration: credential_unavailable")) + }, + || { + let params = params.clone(); + async move { + handler + .exec_command(Parameters(params)) + .await + .map(|result| result.0) + .map_err(|raw| serde_json::from_str(&raw).unwrap()) + } + }, + ) + .await + } + + /// Each new execution in a `gov` workspace runs once under DevGuard, on a pipe and on a PTY, + /// and so does one held for approval and resumed; the lease is released when it ends, the + /// other tools keep working, and DevGuard releases every attempt afterwards. + async fn assert_governed_runs_once( + handler: &CodeSpace, + store: &Store, + fixture: &mut FixtureAuthority, + ) { + let script = "echo argv0:$0; if [ -t 0 ]; then echo tty; stty size; else echo pipe; fi"; + for tty in [false, true] { + let result = executed(handler, exec_params("gov", &["/bin/sh", "-c", script], tty)) + .await + .unwrap_or_else(|body| panic!("tty={tty}: {body:?}")); + assert_eq!(result.dispatch_status, ExecDispatchStatus::Confirmed); + let (output, status) = finished(handler, &result.process_id).await; + let expected = if tty { + "argv0:/bin/sh\ntty\n24 80\n" + } else { + "argv0:/bin/sh\npipe\n" + }; + assert_eq!(output, expected, "tty={tty}"); + assert_eq!( + (status.termination, status.exit_code), + (Some(codespace_domain::ProcessTermination::Exited), Some(0)) + ); + // The lease ends with the process, as for any process. + for _ in 0..200 { + if store.try_acquire_write("gov").is_ok() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + drop( + store + .try_acquire_write("gov") + .expect("the lease is released"), + ); + } + // Held for approval, granted, resumed: launched like a new execution. + let held = handler + .exec_command(Parameters(exec_params( + "gov-confirm", + &["/bin/echo", "resumed"], + false, + ))) + .await + .err() + .expect("held"); + let held: ErrorBody = serde_json::from_str(&held).unwrap(); + assert_eq!(held.code, ErrorCode::ApprovalRequired); + let approval_id = ApprovalId(held.approval_id.unwrap()); + handler + .approval_resolve(Parameters(codespace_domain::ApprovalResolveParams { + approval_id: approval_id.clone(), + decision: codespace_domain::ApprovalDecision::Grant, + })) + .await + .unwrap(); + let resumed = handler + .operation_resume(Parameters(OperationResumeParams { + approval_id: approval_id.clone(), + })) + .await + .unwrap() + .0; + let launched = resumed.exec_command.expect("an exec result"); + assert_eq!(launched.dispatch_status, ExecDispatchStatus::Confirmed); + assert_eq!(finished(handler, &launched.process_id).await.0, "resumed\n"); + // Resuming it again does not launch it again. + let again = handler + .operation_resume(Parameters(OperationResumeParams { approval_id })) + .await + .unwrap() + .0; + assert_eq!( + again.exec_command.map(|result| result.process_id), + Some(launched.process_id) + ); + // An ungoverned workspace runs as before. + let plain = handler + .exec_command(Parameters(exec_params( + "demo", + &["/bin/echo", "plain"], + false, + ))) + .await + .unwrap() + .0; + assert_eq!(finished(handler, &plain.process_id).await.0, "plain\n"); + assert_eq!(released(fixture), (Vec::new(), 0)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn the_in_process_gateway_launches_each_governed_execution_once() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry(dir.path()), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + if !fixture.native { + // Without native evidence the owner cannot register, so nothing is launched. + let body = executed(&handler, exec_params("gov", &["/bin/echo"], false)) + .await + .unwrap_err(); + assert_eq!(body.code, ErrorCode::ResourceAuthorityUnavailable); + assert!(body.message.ends_with("nothing was started"), "{body:?}"); + return; + } + let report = reported_past_the_credential(&handler).await; + assert_eq!(report["resource_authority"]["governs_execution"], true); + assert_governed_runs_once(&handler, &store, &mut fixture).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn the_uds_worker_launches_each_governed_execution_once() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let (Some(helper), Some(bin)) = (launch_helper(), devguard_worker()) else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "uds", + "--runtime-bin", + bin.to_str().unwrap(), + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let worker = cli.devguard.worker().unwrap(); + let (mut process, runner) = + RuntimeProcess::spawn_registered(&bin, None, release(&store), store.clone(), &worker) + .await + .unwrap(); + assert!(runner.states_launch()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry(dir.path()), + store.clone(), + RuntimeBackend::Uds(runner), + ); + if fixture.native { + let report = reported_past_the_credential(&handler).await; + assert_eq!(report["resource_authority"]["governs_execution"], true); + assert_governed_runs_once(&handler, &store, &mut fixture).await; + // The worker is the only instance, and the helper's parent. + assert_eq!(fixture.instances().len(), 1); + } else { + let body = executed(&handler, exec_params("gov", &["/bin/echo"], false)) + .await + .unwrap_err(); + assert_eq!(body.code, ErrorCode::ResourceAuthorityUnavailable); + } + process.wait_exit().await; + } + + /// A governed process is CodeSpace's: its stdin, its PTY's size, termination and status, + /// with no DevGuard session, and DevGuard settles it once it ended. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_governed_process_is_controlled_like_any_other() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry(dir.path()), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + // A PTY: its size changes, and it leads its own session on its terminal. + let script = "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; \ + ps -o pid=,pgid=,sess= -p $$ | awk '{ print ($1 == $2 ? \"leader\" : \"member\") }'; \ + IFS= read line; printf 'after:%s %s\\n' \"$(stty size)\" \"$line\""; + let pty = executed( + &handler, + exec_params("gov", &["/bin/sh", "-c", script], true), + ) + .await + .unwrap(); + for _ in 0..500 { + let read = handler + .read_process(Parameters(ReadProcessParams { + process_id: pty.process_id.clone(), + cursor: 0, + })) + .await + .unwrap() + .0; + if read.chunk.contains("leader") || read.chunk.contains("member") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + handler + .process_resize(Parameters(ProcessResizeParams { + process_id: pty.process_id.clone(), + rows: 40, + cols: 120, + })) + .await + .unwrap(); + handler + .write_stdin(Parameters(WriteStdinParams { + process_id: pty.process_id.clone(), + data: "go\n".into(), + })) + .await + .unwrap(); + let (output, status) = finished(&handler, &pty.process_id).await; + assert_eq!(output, "start:24 80\nleader\nafter:40 120 go\n"); + assert_eq!(status.exit_code, Some(0)); + // A pipe: stdin reaches it, and termination ends it. + // The shell becomes `sleep`, so terminating the root ends the scope: on a pipe, + // termination kills the root only, and a descendant left in the scope would keep the + // attempt charged until it ended (CSRG-U5). + let pipe = executed( + &handler, + exec_params( + "gov", + &["/bin/sh", "-c", "read line; echo got:$line; exec sleep 30"], + false, + ), + ) + .await + .unwrap(); + handler + .write_stdin(Parameters(WriteStdinParams { + process_id: pipe.process_id.clone(), + data: "in\n".into(), + })) + .await + .unwrap(); + // The command read its stdin and answered before it is terminated. + for _ in 0..500 { + let read = handler + .read_process(Parameters(ReadProcessParams { + process_id: pipe.process_id.clone(), + cursor: 0, + })) + .await + .unwrap() + .0; + if read.chunk.contains("got:in") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_process_control_needs_no_registration(&handler, pipe.process_id.clone()).await; + let (output, status) = finished(&handler, &pipe.process_id).await; + assert_eq!(output, "got:in\n"); + assert_eq!( + status.termination, + Some(codespace_domain::ProcessTermination::Terminated) + ); + assert_eq!(released(&mut fixture), (Vec::new(), 0)); + } + + /// D6 for the launch carriers: governed launches run concurrently with ordinary pipe and PTY + /// children of the same gateway. No child but each launch's own helper may hold its permit + /// carrier or transcript; no governed command holds anything beyond its standard + /// descriptors; and a descriptor this process leaves inheritable reaches only the + /// unguarded control spawn. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_launches_pass_their_descriptors_to_no_other_child() { + use std::os::fd::AsRawFd; + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let runner = codespace_runner::InProcessRunner::new(release(&store)).with_max_processes(32); + let handler = CodeSpace::from_cli( + &cli, + launch_registry_with(dir.path(), 4), + store.clone(), + RuntimeBackend::InProcess(runner), + ); + let secret = std::fs::read_to_string(&fixture.settings.credential_file).unwrap(); + // The positive control: a socket inheritable in this process, as one is inside + // DevGuard's connect window on macOS. + let (control, _peer) = crate::devguard::tests::inheritable_socket(); + let control_fd = control.as_raw_fd(); + // Every descriptor 3..1024 a child holds, and which are sockets or pipes. + let probe = "fds=; sockets=; pipes=; n=3; while [ $n -lt 1024 ]; do \ + if [ -e /dev/fd/$n ]; then fds=\"$fds $n\"; \ + if [ -S /dev/fd/$n ]; then sockets=\"$sockets $n\"; fi; \ + if [ -p /dev/fd/$n ]; then pipes=\"$pipes $n\"; fi; fi; n=$((n + 1)); done; \ + echo \"fds:$fds\"; echo \"sockets:$sockets\"; echo \"pipes:$pipes\"; env"; + let field = |output: &str, name: &str| -> String { + output + .lines() + .find_map(|line| line.strip_prefix(name)) + .unwrap_or_else(|| panic!("no {name} in {output:?}")) + .trim() + .to_owned() + }; + let rounds = 6; + let (mut governed, mut ordinary, mut control_held) = (0, 0, 0); + for round in 0..rounds { + let mut tasks = Vec::new(); + // Two governed launches and four ordinary children at once, each in its own + // workspace, alternating PTYs. + for n in 0..6 { + let handler = handler.clone(); + let workspace = if n < 2 { + format!("gov-{n}") + } else { + format!("demo-{}", n - 2) + }; + let tty = (round + n) % 2 == 1; + tasks.push(tokio::spawn(async move { + let result = executed( + &handler, + exec_params(&workspace, &["/bin/sh", "-c", probe], tty), + ) + .await + .unwrap_or_else(|body| panic!("{workspace}: {body:?}")); + let output = finished(&handler, &result.process_id).await.0; + (workspace, tty, output) + })); + } + // The unguarded control spawns while the launches run. + let controls: Vec<_> = (0..4) + .map(|_| { + std::process::Command::new("/bin/sh") + .args(["-c", probe]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .unwrap() + }) + .collect(); + for task in tasks { + let (workspace, tty, output) = task.await.unwrap(); + assert!( + !output.contains(&secret), + "{workspace} tty={tty} saw the secret" + ); + assert_eq!( + ( + field(&output, "fds:"), + field(&output, "sockets:"), + field(&output, "pipes:") + ), + (String::new(), String::new(), String::new()), + "{workspace} tty={tty}: {output}" + ); + if workspace.starts_with("gov") { + governed += 1; + } else { + ordinary += 1; + } + } + for output in controls { + let output = String::from_utf8(output.stdout).unwrap(); + let fds = field(&output, "fds:"); + if fds.split(' ').any(|fd| fd == control_fd.to_string()) { + control_held += 1; + } + } + } + assert_eq!( + control_held, + rounds * 4, + "the control must see the inheritable descriptor" + ); + assert_eq!(released(&mut fixture), (Vec::new(), 0)); + eprintln!( + "u4 d6: {governed} governed launches and {ordinary} ordinary children held no \ + descriptor beyond stdio; the unguarded control held the inheritable descriptor \ + {control_held}/{}", + rounds * 4 + ); + drop(control); + } } diff --git a/docs/devguard-integration.md b/docs/devguard-integration.md index 2da3a50..4b657f5 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard integration roadmap -> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03, and the [admission and pre-spawn preparation](#devguard-admission) of CSRG-U3, which the owner directed on 2026-10-07. +> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03, the [admission and pre-spawn preparation](#devguard-admission) of CSRG-U3, which the owner directed on 2026-10-07, and the [managed launch](#devguard-launch) of CSRG-U4, which the owner directed on 2026-10-08. [English](devguard-integration.md) | [ํ•œ๊ตญ์–ด](ko/devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard) is an independent resource authority for development workloads, licensed under Apache-2.0 like CodeSpace. Its approved integration path adds a shared admission and accounting layer while CodeSpace keeps process ownership, PTY, input/output, permissions, approval holds and workspace coordination. -**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`6e7e065`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation. With CSRG-U3, that owner takes a process slot and has DevGuard admit each such execution before anything is created; as managed launch does not exist yet (CSRG-U4), it then cancels the unstarted attempt and refuses the execution. Nothing is launched through DevGuard and nothing runs without it, so a running DevGuard service does not yet govern a running CodeSpace process. The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. +**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`6e7e065`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation. With CSRG-U3, that owner takes a process slot and has DevGuard admit each such execution before anything is created. With CSRG-U4 and a configured launch helper (`--devguard-launch-helper`), it then launches the execution once through DevGuard's launch helper, which DevGuard authorizes after binding and policing its scope; without a helper it cancels the unstarted attempt and refuses the execution. Nothing runs without DevGuard. Observation before reaping, release and qualification are later units (CSRG-U5, U6). The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. DevGuard [design revision 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/design-revision-1.md), merged in [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8) as `d4981b4c241cff42687f5c2c681b583c7847776e`, revised the CS-RG execution layer and work order. It changes plans and design decisions that bind later implementation. It does not change current CodeSpace behavior or any implementation or qualification status. @@ -87,7 +87,7 @@ DevGuard takes the registered process from the session's peer, so a process can **The session.** Each registration is one bounded session that the owner opens and closes: connect, `Hello` requiring protocol 1 and the capability `static_control_reservations`, `Authenticate`, which must grant `control_service`, `Status`, which must report registration ready, then `Register` with the owner's instance identity. DevGuard's 250 ms frame deadlines bound it at about 2.25 s. The instance identity, `codespace-` and 32 random hexadecimal digits, is minted once per owner process and registered again by every session, so DevGuard keeps one instance per owner: active while a session lasts, suspect after it. A restarted owner is a new process with a new identity. The owner registers at startup and for each `workspace_info` call; a call that arrives while a session runs waits and shares the next one. Each admission, lookup or cancellation of CSRG-U3 is its own session that registers first. -**What it reports.** With `register`, `workspace_info.resource_authority` comes from the owner's session: `participation: registration`, `governs_execution: false`, the authority's `state`, `error_code` and `report` as with status, and `registration`: +**What it reports.** With `register`, `workspace_info.resource_authority` comes from the owner's session: `participation: registration`, `governs_execution` (`true` only when the owner is registered and has a launch helper, CSRG-U4), the authority's `state`, `error_code` and `report` as with status, and `registration`: - `owner`: `in_process` or `worker`; - `state`: `registered`, `unavailable`, `untrusted_authority`, `incompatible`, `credential_unavailable`, `credential_refused`, `role_mismatch`, `not_ready`, `refused`, `owner_mismatch`, `owner_unreachable` or `unsupported_mode`; @@ -115,13 +115,13 @@ When the owner cannot be asked (`owner_unreachable`, `unsupported_mode`), the au **Platforms.** On macOS, a DevGuard service with native host evidence registers the owner. Without native evidence, as on Linux before DG-LINUX, DevGuard states no capability, so registration is `incompatible`. -**What it does not do.** No launch, permit, carrier or launch helper. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. +**What it does not do.** Registration alone launches nothing: launch is CSRG-U4's. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. ## Admission and pre-spawn preparation (CSRG-U3) -CSRG-U3 makes `required` operational up to launch: the execution owner admits each governed execution through DevGuard and holds a one-shot preparation of it. It still starts nothing. Managed launch (`BeginLaunch`, its permit and carrier, the launch helper) is CSRG-U4. +CSRG-U3 makes `required` operational up to launch: the execution owner admits each governed execution through DevGuard and holds a one-shot preparation of it, which starts nothing. Its [managed launch](#devguard-launch) (`BeginLaunch`, its permit and carriers, the launch helper) is CSRG-U4. **Order.** A new execution in a `required` workspace, with `--devguard register`, goes through: @@ -146,7 +146,7 @@ Without `request`, these are the values. Each quantity must be positive; `minimu **The preparation.** It keeps four stages apart: what was *requested* and the *required* minimum, what DevGuard *supports* for it (scope and, per resource, level and method), what it *reserved* and for how long it holds it unlaunched, and what was *applied*, which stays `not_launched`. It cannot be cloned; launching or cancelling consumes it, so it is used once. Its deadline is DevGuard's prepared lifetime (5 s at the pin), measured from before the admission was asked. Presenting another command, `process_id` or PTY choice for launch is refused and cancels the attempt; so does presenting it after its deadline. A preparation dropped without being settled is cancelled in the background. -**Until managed launch exists.** After a successful preparation the owner cancels the unstarted attempt and the execution is refused with `MANAGED_LAUNCH_UNAVAILABLE`. Nothing ever runs through another path instead. Every outcome is a refusal that starts nothing, releases the workspace lease and the slot, and states the attempt ID, the `process_id` and what is known of the attempt (`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other` or `unknown`): +**Without a launch helper.** After a successful preparation an owner without a launch helper cancels the unstarted attempt and the execution is refused with `MANAGED_LAUNCH_UNAVAILABLE`; with one, the preparation is [launched](#devguard-launch). Nothing ever runs through another path instead. Every outcome is a refusal that starts nothing, releases the workspace lease and the slot, and states the attempt ID, the `process_id` and what is known of the attempt (`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other` or `unknown`): | Outcome | Tool error | | --- | --- | @@ -165,7 +165,67 @@ A resumed approval is prepared like a new execution, and resuming it again retur **Platforms.** On macOS, with a DevGuard service that has native host evidence, executions are admitted and cancelled. Without native evidence, as on Linux before DG-LINUX, the owner cannot register, so every governed execution is `RESOURCE_AUTHORITY_UNAVAILABLE`. -**What it does not do.** No `BeginLaunch`, permit, carrier, launch helper, observation, reaping or release integration, and no pin change. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. +**What it does not do.** Preparation sends no `BeginLaunch` and starts nothing; launch is CSRG-U4's. No observation, reaping or release integration, and no pin change. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. + + + +## Managed launch (CSRG-U4) + +CSRG-U4 launches a prepared execution once under DevGuard: with `required`, the command now runs, through DevGuard's launch helper and CodeSpace's own pipe and PTY spawners. + +- **Run.** `--devguard register` as before, plus `--devguard-launch-helper PATH` (`CODESPACE_DEVGUARD_LAUNCH_HELPER`): the absolute path of `devguard-launch` from the DevGuard release that serves the socket, which `devguard doctor` names. In UDS mode the gateway passes the path to the worker it starts. Without it a governed execution is admitted, then refused with `MANAGED_LAUNCH_UNAVAILABLE`, as in CSRG-U3. + +**Order.** After the [preparation](#devguard-admission) of CSRG-U3, the execution owner: + +1. **checks what it can before committing anything**: the prepared command and its deadline; the helper, which must be a regular executable file that no other user can replace (owned by this user or root, not writable by group or others, under directories with the same property); and the program, resolved to an absolute path on the execution's own `PATH` as the spawn would resolve it. A failure here cancels the unstarted attempt. +2. **sends `BeginLaunch` once**, in a bounded session of the registered owner (`Register`, then `BeginLaunch`). Only its first answer carries the attempt's one-time permit, and only a permit that comes with this owner's committed record of this attempt and meaning is accepted. +3. **starts the helper through its own spawner**, as its own direct child, filling the slot the preparation took. DevGuard's `helper_command` builds the invocation: the helper's arguments, which carry no secret, and two private descriptors, the permit carrier (a socket pair holding the permit, its writer closed) and the writer of the helper's transcript (a pipe). Only the helper receives them. +4. **reads the transcript** to its end. The helper leads its own process group and presents the grant; DevGuard binds that group as the attempt's scope, applies and reads back the policy, and authorizes the run. The helper then reports READY and executes the program, which keeps the helper's PID. READY followed by the transcript's end is a started command. + +DevGuard requires the helper's parent to be the registered owner, so the owner launches: the gateway in `in-process` mode, and in `uds` mode the worker the gateway started, which receives one `launch` request. The worker states `launch` in its `Hello` only when it registers and has a helper; the request is never sent again. + +**The process.** From the spawn on it is a CodeSpace process like any other: the same slot, output, stdin, `process_resize`, timeout, `terminate_process`, workspace lease and reaping, under the prepared `process_id`. It differs from an ungoverned execution in three ways: + +- the command runs with its resolved absolute path as `argv[0]`; +- on a pipe it leads its own process group, as the helper makes it; on a PTY it leads its own session, as before; +- on a PTY, a death by a signal is reported as exit code 128 plus the signal number instead of 1, as the pinned Codex PTY's descriptor-keeping path reports it. + +**Results.** `exec_command` returns once the transcript has ended, or after 10 s: + +| Transcript | Result | +| --- | --- | +| READY, then its end | `dispatch_status: confirmed` | +| not ended in time, or not well formed | `dispatch_status: unknown`: the process is CodeSpace's either way, and whether the command started is not known | +| READY, then `exec_failed` | `PROCESS_SPAWN_FAILED`; the helper exits 126 or 127, and DevGuard settles the attempt through its scope | +| refused, failed or ended before READY | the command never ran; once CodeSpace has reaped the helper, the owner reports that it holds no helper (`AbandonLaunch`) | + +Every refusal states the attempt ID, the `process_id` and what is known of the attempt. It ends with "nothing was started" when no helper process existed, and with "the command was not started" when one did. Launch adds three attempt states: `released` (DevGuard released the unclaimed grant as never started), `launched` (a helper claimed it, and DevGuard holds it until it observes the scope's end) and `scope_ended`. + +| Outcome | Tool error | +| --- | --- | +| No launch helper, or one that cannot be used | `MANAGED_LAUNCH_UNAVAILABLE` | +| The program is not an executable file on its `PATH`, or `Enabled` network, which needs the Linux command sandbox | `PROCESS_SPAWN_FAILED` | +| The Linux command sandbox would wrap the command | `RESOURCE_POLICY_UNSUPPORTED` | +| DevGuard refused `BeginLaunch`, or did not authorize the helper | `RESOURCE_UNAVAILABLE` for `resource_unavailable`, `RESOURCE_POLICY_UNSUPPORTED` for `resource_policy_unsupported`, otherwise `RESOURCE_AUTHORITY_UNAVAILABLE` | +| No answer to `BeginLaunch` | `ADMISSION_UNKNOWN` while the attempt is unknown, otherwise `RESOURCE_AUTHORITY_UNAVAILABLE` | +| The helper failed before presenting the grant | `RESOURCE_AUTHORITY_UNAVAILABLE` | +| The helper could not be started, or ended before READY without a report | `PROCESS_SPAWN_FAILED` | + +**One launch, no replay.** `BeginLaunch` is sent at most once per attempt. A timeout, a lost answer, an EOF, or an answer without the permit or for anything else, is not a refusal: the owner looks the attempt up once. Still prepared: the launch was not committed, so the attempt is cancelled. Committed and unclaimed: the permit never arrived and no helper exists, so the owner reports it (`AbandonLaunch`, `grant_not_received`) and DevGuard releases it as never started. Otherwise the attempt is reported as found, or as `unknown`, and is never launched again under any ID. A helper that could not be spawned is reported the same way (`spawn_failed`), and one that ended before READY once it is reaped (`helper_exited`). A report never releases a grant a helper claimed; only the end of its scope does. In `uds` mode a `launch` request the worker may have received without answering returns `dispatch_status: unknown` for its `process_id`; one that never left is `RESOURCE_AUTHORITY_UNAVAILABLE` (`owner_unreachable`). + +**Carriers and children.** The permit carrier and the transcript reach the helper and no other child: + +- on a pipe, the helper's spawn marks every descriptor above 2 close-on-exec in the child except the two, and clears close-on-exec on those two there only, as the [#79](https://github.com/novelKR/CodeSpace/issues/79) exclusion does for every CodeSpace spawn; the spawn fails rather than start a helper without them; +- on a PTY, the pinned Codex PTY passes on only descriptors that are already inheritable (`inherited_fds`), so the two are inheritable in the owner's process from just before that spawn until it returns. Every other spawner in that process (pipe, PTY, patch and sandbox helpers, the worker) excludes unrelated descriptors from its children, so none of them receives the two meanwhile, and the Codex PTY's child closes every other inheritable descriptor itself; +- the owner's copies close as soon as the spawn returns. The helper consumes and closes the permit, marks the transcript close-on-exec and closes its own authority session before the executable starts, which therefore holds none of them. + +Tests check each with controls: a deliberately inheritable descriptor; stand-in helpers that report exactly what they received; an unguarded child spawned inside the PTY window, which holds both descriptors while a guarded one holds neither; and governed launches running concurrently with ordinary pipe and PTY children of one gateway, against DevGuard's fixture authority and its launch helper. + +**Account.** A launched execution's account adds, per resource, what DevGuard applied, read from DevGuard's record after READY, and the scope's root, which is the helper's PID. `workspace_info.resource_authority.governs_execution` is `true` when the execution owner is registered and has a launch helper. + +**Supported.** macOS with a DevGuard service that has native host evidence: `in-process`, and `uds` with `--runtime-bin`, each on a pipe and on a PTY. Unsupported: `uds` with `--runner-socket` and Linux before DG-LINUX, where the owner cannot register; the Linux command sandbox and `Enabled` network, which are refused before anything is committed. + +**Not yet (CSRG-U5).** CodeSpace reaps and terminates the process as before, without asking DevGuard to observe or signal the scope; on a pipe, `terminate_process` and a timeout kill the root only. DevGuard releases a scope whose members all ended with its root. A descendant that outlives the root keeps the attempt charged while it runs; one that CodeSpace's reap leaves before DevGuard observed it leaves the attempt suspect. Approval dispatch states, release on every termination path and qualification are not claimed. ## CS-RG work order diff --git a/docs/ko/devguard-integration.md b/docs/ko/devguard-integration.md index 81ef022..6e6479b 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard ๊ฒฐํ•ฉ ๋กœ๋“œ๋งต -> **์ƒํƒœ: ๊ตฌํ˜„ ์ง€์‹œ๋กœ์„œ ํšจ๋ ฅ ์ค‘์ง€.** ์ด ํŽ˜์ด์ง€์˜ โ€˜CS-RG ์ž‘์—… ์ˆœ์„œโ€™, โ€˜์†Œ๋น„ ๊ฒฝ๊ณ„โ€™, โ€˜์„ค๊ณ„ ๊ฐœ์ • 1 ์ฐธ์กฐโ€™ ์ ˆ์€ ์ ํžŒ ๋Œ€๋กœ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ์ƒํƒœ์™€ ์„ ํ–‰ ์กฐ๊ฑด์€ ์˜ํ–ฅ์ด ์—†์Šต๋‹ˆ๋‹ค. CodeSpace ํ†ตํ•ฉ ๊ณ„ํš์— ๋Œ€ํ•ด ์†Œ์œ ์ž๊ฐ€ ์ง€์‹œํ•œ ๊ฒ€ํ† ์ธ CS-RG ํ†ตํ•ฉ ๊ฒฝ๊ณ„ ์žฌ๊ฒ€์ฆ(์ž‘์—… ๋‹จ์œ„๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค)์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋Œ€์ฒด ๊ตฌ์กฐ๋Š” ์Šน์ธ๋˜์ง€ ์•Š์•˜์œผ๋ฉฐ, ์†Œ์œ ์ž๊ฐ€ ์žฌ๊ฒ€์ฆ ๊ฒฐ๊ณผ๋ฅผ ๊ฒ€ํ† ํ•œ ๋’ค ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ‘œ๊ธฐ๋Š” ์ง€์‹œ์˜ ํšจ๋ ฅ๋งŒ ์ค‘์ง€ํ•˜๋ฉฐ ์–ด๋–ค ์•ˆ์ „ ์š”๊ตฌ๋„ ์™„ํ™”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์•„๋ž˜ ๋‚ด์šฉ์€ ์ด๋ ฅ ์ถ”์ ์„ ์œ„ํ•ด ๋ฐ”๊พธ์ง€ ์•Š๊ณ  ๋‚จ๊น๋‹ˆ๋‹ค. ๋‹ค๋งŒ ํ˜„์žฌ ์ƒํƒœ, ์†Œ์œ ์ž๊ฐ€ 2026-10-02์— ์ด ํšจ๋ ฅ ์ค‘์ง€์™€ ๋ณ„๋„๋กœ ์Šน์ธํ•œ CSRG-U1์˜ [์ƒํƒœ ์ „์šฉ ์—ฐ๊ฒฐ](#devguard-status-connection), ์†Œ์œ ์ž๊ฐ€ 2026-10-03์— ์ง€์‹œํ•œ CSRG-U2์˜ [์‹คํ–‰ ์†Œ์œ ์ž ๋“ฑ๋ก](#devguard-owner-registration), ์†Œ์œ ์ž๊ฐ€ 2026-10-07์— ์ง€์‹œํ•œ CSRG-U3์˜ [์‹คํ–‰ ํ—ˆ์šฉ๊ณผ spawn ์ „ ์ค€๋น„](#devguard-admission)๋Š” ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค. +> **์ƒํƒœ: ๊ตฌํ˜„ ์ง€์‹œ๋กœ์„œ ํšจ๋ ฅ ์ค‘์ง€.** ์ด ํŽ˜์ด์ง€์˜ โ€˜CS-RG ์ž‘์—… ์ˆœ์„œโ€™, โ€˜์†Œ๋น„ ๊ฒฝ๊ณ„โ€™, โ€˜์„ค๊ณ„ ๊ฐœ์ • 1 ์ฐธ์กฐโ€™ ์ ˆ์€ ์ ํžŒ ๋Œ€๋กœ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ์ƒํƒœ์™€ ์„ ํ–‰ ์กฐ๊ฑด์€ ์˜ํ–ฅ์ด ์—†์Šต๋‹ˆ๋‹ค. CodeSpace ํ†ตํ•ฉ ๊ณ„ํš์— ๋Œ€ํ•ด ์†Œ์œ ์ž๊ฐ€ ์ง€์‹œํ•œ ๊ฒ€ํ† ์ธ CS-RG ํ†ตํ•ฉ ๊ฒฝ๊ณ„ ์žฌ๊ฒ€์ฆ(์ž‘์—… ๋‹จ์œ„๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค)์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋Œ€์ฒด ๊ตฌ์กฐ๋Š” ์Šน์ธ๋˜์ง€ ์•Š์•˜์œผ๋ฉฐ, ์†Œ์œ ์ž๊ฐ€ ์žฌ๊ฒ€์ฆ ๊ฒฐ๊ณผ๋ฅผ ๊ฒ€ํ† ํ•œ ๋’ค ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ‘œ๊ธฐ๋Š” ์ง€์‹œ์˜ ํšจ๋ ฅ๋งŒ ์ค‘์ง€ํ•˜๋ฉฐ ์–ด๋–ค ์•ˆ์ „ ์š”๊ตฌ๋„ ์™„ํ™”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์•„๋ž˜ ๋‚ด์šฉ์€ ์ด๋ ฅ ์ถ”์ ์„ ์œ„ํ•ด ๋ฐ”๊พธ์ง€ ์•Š๊ณ  ๋‚จ๊น๋‹ˆ๋‹ค. ๋‹ค๋งŒ ํ˜„์žฌ ์ƒํƒœ, ์†Œ์œ ์ž๊ฐ€ 2026-10-02์— ์ด ํšจ๋ ฅ ์ค‘์ง€์™€ ๋ณ„๋„๋กœ ์Šน์ธํ•œ CSRG-U1์˜ [์ƒํƒœ ์ „์šฉ ์—ฐ๊ฒฐ](#devguard-status-connection), ์†Œ์œ ์ž๊ฐ€ 2026-10-03์— ์ง€์‹œํ•œ CSRG-U2์˜ [์‹คํ–‰ ์†Œ์œ ์ž ๋“ฑ๋ก](#devguard-owner-registration), ์†Œ์œ ์ž๊ฐ€ 2026-10-07์— ์ง€์‹œํ•œ CSRG-U3์˜ [์‹คํ–‰ ํ—ˆ์šฉ๊ณผ spawn ์ „ ์ค€๋น„](#devguard-admission), ์†Œ์œ ์ž๊ฐ€ 2026-10-08์— ์ง€์‹œํ•œ CSRG-U4์˜ [๊ด€๋ฆฌํ˜• ์‹คํ–‰](#devguard-launch)์€ ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค. [English](../devguard-integration.md) | [ํ•œ๊ตญ์–ด](devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard)๋Š” ๊ฐœ๋ฐœ ์ž‘์—…์˜ ์ž์›์„ ์ค‘์•™์—์„œ ๊ด€๋ฆฌํ•˜๋Š” ๋…๋ฆฝ ์‹œ์Šคํ…œ์ด๋ฉฐ CodeSpace์™€ ๋™์ผํ•œ Apache-2.0 ๋ผ์ด์„ ์Šค๋ฅผ ์ ์šฉํ•œ๋‹ค. ์Šน์ธ๋œ ๊ฒฐํ•ฉ ๊ฒฝ๋กœ์— ๋”ฐ๋ผ ์‹คํ–‰ ํ—ˆ์šฉ๊ณผ ์ž์› ํšŒ๊ณ„๋ฅผ ๊ณตํ†ต ๊ณ„์ธต์— ๋งก๊ธฐ๊ณ , CodeSpace๋Š” ํ”„๋กœ์„ธ์Šค ์†Œ์œ ๊ถŒ, PTY, ์ž…์ถœ๋ ฅ, ๊ถŒํ•œ, ์Šน์ธ hold์™€ workspace ์กฐ์ •์„ ๊ณ„์† ๋‹ด๋‹นํ•œ๋‹ค. -**ํ˜„์žฌ ์ƒํƒœ:** DevGuard๋Š” ๋…๋ฆฝ ์ €์žฅ์†Œ์—์„œ DG-1์„ ์™„๋ฃŒํ–ˆ๋‹ค([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [๋งˆ์ผ์Šคํ†ค ์›์žฅ](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 ๊ณ„์•ฝ ์œ„์— macOS ์‹ค์ œ ํ˜ธ์ŠคํŠธ ์ฆ๊ฑฐ, ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ LaunchAgent๋กœ ์„ค์น˜ํ•˜๋Š” ์ธ์ฆ daemon, fenced launch helper์™€ ๋Œ€์กฐ, Cargo adapter๋ฅผ ๊ฐ–์ถ˜ `devguard` ๋ช…๋ นํ–‰ owner, ํ›„๋ณด ์‹œํ—˜์šฉ parent lease, upgrade์™€ repair๋ฅผ ์ œ๊ณตํ•œ๋‹ค. ์ธก์ •ํ•œ ํ˜ธ์ŠคํŠธ์™€ ์ •์ฑ…์—์„œ release `0.1.0-5daee5d-b3fa569e`(Git tag๊ฐ€ ์•„๋‹Œ release ID)์˜ macOS SLO qualification์„ ๋งˆ์ณค์œผ๋ฉฐ Linux ๊ฐ•์ œ ๋ณดํ˜ธ๋Š” qualificationํ•˜์ง€ ์•Š์•˜๋‹ค. CodeSpace ์†Œ๋น„(CS-RG)๋Š” ์ง„ํ–‰ ์ค‘์ด๋ฉฐ qualification์„ ๋งˆ์น˜์ง€ ์•Š์•˜๋‹ค. CSRG-U1์€ opt-in ์ƒํƒœ ์ „์šฉ ์—ฐ๊ฒฐ์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. `devguard` feature๋กœ ๋นŒ๋“œํ•˜๊ณ  `--devguard status`๋กœ ์‹œ์ž‘ํ•œ gateway๋Š” [`6e7e065`](upstream-lock.md#devguard-client-pin)์— ๊ณ ์ •ํ•œ DevGuard client๋กœ DevGuard์˜ ์ƒํƒœ๋ฅผ `workspace_info`์— ๋ณด๊ณ ํ•œ๋‹ค. CSRG-U2๋ถ€ํ„ฐ๋Š” CodeSpace์˜ ์‹คํ–‰์„ ์†Œ์œ ํ•œ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์Šค์Šค๋กœ๋ฅผ DevGuard์— ๋“ฑ๋กํ•˜๋ฉฐ(`--devguard register`), workspace์˜ `resources` ์„ค์ •์œผ๋กœ ์ž์› ์ฐธ์—ฌ๋ฅผ ํ•„์ˆ˜๋กœ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. CSRG-U3๋ถ€ํ„ฐ ๊ทธ ์†Œ์œ ์ž๋Š” ์ด๋Ÿฐ ์‹คํ–‰๋งˆ๋‹ค ๋ฌด์—‡์„ ๋งŒ๋“ค๊ธฐ ์ „์— ํ”„๋กœ์„ธ์Šค slot์„ ์žก๊ณ  DevGuard์˜ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฐ›๋Š”๋‹ค. ๊ด€๋ฆฌํ˜• ์‹คํ–‰(CSRG-U4)์ด ์•„์ง ์—†์œผ๋ฏ€๋กœ ๊ทธ๋‹ค์Œ ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•˜๊ณ  ์‹คํ–‰์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. DevGuard๋ฅผ ํ†ตํ•ด ์‹คํ–‰ํ•˜๋Š” ๊ฒƒ๋„, DevGuard ์—†์ด ์‹คํ–‰ํ•˜๋Š” ๊ฒƒ๋„ ์—†์œผ๋ฏ€๋กœ DevGuard ์„œ๋น„์Šค๊ฐ€ ์‹คํ–‰ ์ค‘์ด์–ด๋„ ์•„์ง ์‹คํ–‰ ์ค‘์ธ CodeSpace ํ”„๋กœ์„ธ์Šค๋ฅผ ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š”๋‹ค. qualification์„ ๋งˆ์นœ release๋Š” ์‹คํ–‰์— ์“ธ ํ›„๋ณด๋กœ ๋‚จ์œผ๋ฉฐ ์„ ํƒ๋œ pin์ด ์•„๋‹ˆ๋‹ค. client pin์€ client crate์˜ ์†Œ์Šค pin์ด๋‹ค. +**ํ˜„์žฌ ์ƒํƒœ:** DevGuard๋Š” ๋…๋ฆฝ ์ €์žฅ์†Œ์—์„œ DG-1์„ ์™„๋ฃŒํ–ˆ๋‹ค([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [๋งˆ์ผ์Šคํ†ค ์›์žฅ](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 ๊ณ„์•ฝ ์œ„์— macOS ์‹ค์ œ ํ˜ธ์ŠคํŠธ ์ฆ๊ฑฐ, ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ LaunchAgent๋กœ ์„ค์น˜ํ•˜๋Š” ์ธ์ฆ daemon, fenced launch helper์™€ ๋Œ€์กฐ, Cargo adapter๋ฅผ ๊ฐ–์ถ˜ `devguard` ๋ช…๋ นํ–‰ owner, ํ›„๋ณด ์‹œํ—˜์šฉ parent lease, upgrade์™€ repair๋ฅผ ์ œ๊ณตํ•œ๋‹ค. ์ธก์ •ํ•œ ํ˜ธ์ŠคํŠธ์™€ ์ •์ฑ…์—์„œ release `0.1.0-5daee5d-b3fa569e`(Git tag๊ฐ€ ์•„๋‹Œ release ID)์˜ macOS SLO qualification์„ ๋งˆ์ณค์œผ๋ฉฐ Linux ๊ฐ•์ œ ๋ณดํ˜ธ๋Š” qualificationํ•˜์ง€ ์•Š์•˜๋‹ค. CodeSpace ์†Œ๋น„(CS-RG)๋Š” ์ง„ํ–‰ ์ค‘์ด๋ฉฐ qualification์„ ๋งˆ์น˜์ง€ ์•Š์•˜๋‹ค. CSRG-U1์€ opt-in ์ƒํƒœ ์ „์šฉ ์—ฐ๊ฒฐ์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. `devguard` feature๋กœ ๋นŒ๋“œํ•˜๊ณ  `--devguard status`๋กœ ์‹œ์ž‘ํ•œ gateway๋Š” [`6e7e065`](upstream-lock.md#devguard-client-pin)์— ๊ณ ์ •ํ•œ DevGuard client๋กœ DevGuard์˜ ์ƒํƒœ๋ฅผ `workspace_info`์— ๋ณด๊ณ ํ•œ๋‹ค. CSRG-U2๋ถ€ํ„ฐ๋Š” CodeSpace์˜ ์‹คํ–‰์„ ์†Œ์œ ํ•œ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์Šค์Šค๋กœ๋ฅผ DevGuard์— ๋“ฑ๋กํ•˜๋ฉฐ(`--devguard register`), workspace์˜ `resources` ์„ค์ •์œผ๋กœ ์ž์› ์ฐธ์—ฌ๋ฅผ ํ•„์ˆ˜๋กœ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. CSRG-U3๋ถ€ํ„ฐ ๊ทธ ์†Œ์œ ์ž๋Š” ์ด๋Ÿฐ ์‹คํ–‰๋งˆ๋‹ค ๋ฌด์—‡์„ ๋งŒ๋“ค๊ธฐ ์ „์— ํ”„๋กœ์„ธ์Šค slot์„ ์žก๊ณ  DevGuard์˜ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฐ›๋Š”๋‹ค. CSRG-U4๋ถ€ํ„ฐ๋Š” launch helper๋ฅผ ์„ค์ •ํ–ˆ์œผ๋ฉด(`--devguard-launch-helper`) ๊ทธ ์‹คํ–‰์„ DevGuard์˜ launch helper๋กœ ํ•œ ๋ฒˆ ์‹คํ–‰ํ•œ๋‹ค. DevGuard๋Š” helper์˜ scope๋ฅผ ๋ฌถ๊ณ  ์ •์ฑ…์„ ์ ์šฉํ•œ ๋’ค์— ์‹คํ–‰์„ ์Šน์ธํ•œ๋‹ค. helper๊ฐ€ ์—†์œผ๋ฉด ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•˜๊ณ  ์‹คํ–‰์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. DevGuard ์—†์ด ์‹คํ–‰ํ•˜๋Š” ๊ฒƒ์€ ์—†๋‹ค. reap ์ „ ๊ด€์ฐฐ, ํ•ด์ œ, qualification์€ ์ดํ›„ ๋‹จ์œ„(CSRG-U5, U6)๋‹ค. qualification์„ ๋งˆ์นœ release๋Š” ์‹คํ–‰์— ์“ธ ํ›„๋ณด๋กœ ๋‚จ์œผ๋ฉฐ ์„ ํƒ๋œ pin์ด ์•„๋‹ˆ๋‹ค. client pin์€ client crate์˜ ์†Œ์Šค pin์ด๋‹ค. DevGuard [์„ค๊ณ„ ๊ฐœ์ • 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/ko/design-revision-1.md)์€ [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8)์˜ ๋ณ‘ํ•ฉ `d4981b4c241cff42687f5c2c681b583c7847776e`๋กœ ๋ฐ˜์˜๋˜์—ˆ์œผ๋ฉฐ CS-RG์˜ ์‹คํ–‰ ๊ณ„์ธต๊ณผ ์ž‘์—… ์ˆœ์„œ๋ฅผ ๊ฐœ์ •ํ–ˆ๋‹ค. ์ด ๊ฐœ์ •์€ ๊ณ„ํš๊ณผ ์ดํ›„ ๊ตฌํ˜„์„ ๊ตฌ์†ํ•˜๋Š” ์„ค๊ณ„ ๊ฒฐ์ •์„ ๋ฐ”๊พผ๋‹ค. ํ˜„์žฌ CodeSpace ๋™์ž‘์ด๋‚˜ ๊ตฌํ˜„ยทqualification ์ƒํƒœ๋Š” ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค. @@ -87,7 +87,7 @@ DevGuard๋Š” ๋“ฑ๋กํ•  ํ”„๋กœ์„ธ์Šค๋ฅผ ์„ธ์…˜์˜ peer์—์„œ ์–ป์œผ๋ฏ€๋กœ ํ”„๋กœ **์„ธ์…˜.** ๋“ฑ๋ก ํ•œ ๋ฒˆ์€ ์†Œ์œ ์ž๊ฐ€ ์—ด๊ณ  ๋‹ซ๋Š” ์ƒํ•œ ์žˆ๋Š” ์„ธ์…˜ ํ•˜๋‚˜๋‹ค. ์—ฐ๊ฒฐํ•˜๊ณ , protocol 1๊ณผ capability `static_control_reservations`๋ฅผ ์š”๊ตฌํ•˜๋Š” `Hello`, `control_service`๋ฅผ ๋ถ€์—ฌํ•ด์•ผ ํ•˜๋Š” `Authenticate`, ๋“ฑ๋ก ์ค€๋น„๋ฅผ ๋ณด๊ณ ํ•ด์•ผ ํ•˜๋Š” `Status`๋ฅผ ๊ฑฐ์ณ ์†Œ์œ ์ž์˜ instance ์ •์ฒด์„ฑ์œผ๋กœ `Register`ํ•œ๋‹ค. DevGuard์˜ frame๋‹น 250 ms ๊ธฐํ•œ ๋•Œ๋ฌธ์— ์„ธ์…˜์€ ์•ฝ 2.25์ดˆ ์•ˆ์— ๋๋‚œ๋‹ค. instance ์ •์ฒด์„ฑ(`codespace-`์™€ ๋ฌด์ž‘์œ„ 16์ง„์ˆ˜ 32์ž๋ฆฌ)์€ ์†Œ์œ ์ž ํ”„๋กœ์„ธ์Šค๋งˆ๋‹ค ํ•œ ๋ฒˆ ๋งŒ๋“ค๊ณ  ๋ชจ๋“  ์„ธ์…˜์ด ๋‹ค์‹œ ๋“ฑ๋กํ•˜๋ฏ€๋กœ, DevGuard์—๋Š” ์†Œ์œ ์ž๋งˆ๋‹ค instance๊ฐ€ ํ•˜๋‚˜๋งŒ ์žˆ๋‹ค. ์„ธ์…˜ ๋™์•ˆ์—๋Š” active์ด๊ณ  ์„ธ์…˜์ด ๋๋‚˜๋ฉด suspect๊ฐ€ ๋œ๋‹ค. ๋‹ค์‹œ ์‹œ์ž‘ํ•œ ์†Œ์œ ์ž๋Š” ์ƒˆ ํ”„๋กœ์„ธ์Šค์ด๋ฏ€๋กœ ์ƒˆ ์ •์ฒด์„ฑ์„ ์“ด๋‹ค. ์†Œ์œ ์ž๋Š” ์‹œ์ž‘ํ•  ๋•Œ์™€ `workspace_info` ํ˜ธ์ถœ๋งˆ๋‹ค ๋“ฑ๋กํ•œ๋‹ค. ์„ธ์…˜์ด ์ง„ํ–‰ ์ค‘์ผ ๋•Œ ๋“ค์–ด์˜จ ํ˜ธ์ถœ์€ ๊ธฐ๋‹ค๋ ธ๋‹ค๊ฐ€ ๋‹ค์Œ ์„ธ์…˜ ๊ฒฐ๊ณผ๋ฅผ ํ•จ๊ป˜ ์“ด๋‹ค. CSRG-U3์˜ ์‹คํ–‰ ํ—ˆ์šฉ, ์กฐํšŒ, ์ทจ์†Œ๋Š” ๊ฐ๊ฐ ๋จผ์ € ๋“ฑ๋กํ•˜๋Š” ๋ณ„๋„ ์„ธ์…˜์ด๋‹ค. -**๋ณด๊ณ ํ•˜๋Š” ๋‚ด์šฉ.** `register`์ด๋ฉด `workspace_info.resource_authority`๋Š” ์†Œ์œ ์ž์˜ ์„ธ์…˜์—์„œ ๋‚˜์˜จ๋‹ค. `participation: registration`, `governs_execution: false`, ์ƒํƒœ ์—ฐ๊ฒฐ๊ณผ ๊ฐ™์€ authority์˜ `state`, `error_code`, `report`, ๊ทธ๋ฆฌ๊ณ  `registration`: +**๋ณด๊ณ ํ•˜๋Š” ๋‚ด์šฉ.** `register`์ด๋ฉด `workspace_info.resource_authority`๋Š” ์†Œ์œ ์ž์˜ ์„ธ์…˜์—์„œ ๋‚˜์˜จ๋‹ค. `participation: registration`, `governs_execution`(์†Œ์œ ์ž๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ๊ณ  launch helper๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ `true`, CSRG-U4), ์ƒํƒœ ์—ฐ๊ฒฐ๊ณผ ๊ฐ™์€ authority์˜ `state`, `error_code`, `report`, ๊ทธ๋ฆฌ๊ณ  `registration`: - `owner`: `in_process` ๋˜๋Š” `worker`. - `state`: `registered`, `unavailable`, `untrusted_authority`, `incompatible`, `credential_unavailable`, `credential_refused`, `role_mismatch`, `not_ready`, `refused`, `owner_mismatch`, `owner_unreachable`, `unsupported_mode` ์ค‘ ํ•˜๋‚˜. @@ -115,13 +115,13 @@ DevGuard๋Š” ๋“ฑ๋กํ•  ํ”„๋กœ์„ธ์Šค๋ฅผ ์„ธ์…˜์˜ peer์—์„œ ์–ป์œผ๋ฏ€๋กœ ํ”„๋กœ **ํ”Œ๋žซํผ.** macOS์—์„œ๋Š” ์‹ค์ œ ํ˜ธ์ŠคํŠธ ์ฆ๊ฑฐ๋ฅผ ๊ฐ€์ง„ DevGuard ์„œ๋น„์Šค๊ฐ€ ์†Œ์œ ์ž๋ฅผ ๋“ฑ๋กํ•œ๋‹ค. ์‹ค์ œ ์ฆ๊ฑฐ๊ฐ€ ์—†์œผ๋ฉด(DG-LINUX ์ „์˜ Linux) DevGuard๊ฐ€ capability๋ฅผ ํ•˜๋‚˜๋„ ๋ฐํžˆ์ง€ ์•Š์œผ๋ฏ€๋กœ ๋“ฑ๋ก์€ `incompatible`์ด๋‹ค. -**ํ•˜์ง€ ์•Š๋Š” ์ผ.** ์‹คํ–‰, permit, carrier, launch helper๋Š” ์—†๋‹ค. spawn, PTY, ์ถœ๋ ฅ, timeout, ์ข…๋ฃŒ, reap, lifecycle์€ CodeSpace์— ๋‚จ๋Š”๋‹ค. +**ํ•˜์ง€ ์•Š๋Š” ์ผ.** ๋“ฑ๋ก๋งŒ์œผ๋กœ๋Š” ์•„๋ฌด๊ฒƒ๋„ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. ์‹คํ–‰์€ CSRG-U4๊ฐ€ ํ•œ๋‹ค. spawn, PTY, ์ถœ๋ ฅ, timeout, ์ข…๋ฃŒ, reap, lifecycle์€ CodeSpace์— ๋‚จ๋Š”๋‹ค. ## ์‹คํ–‰ ํ—ˆ์šฉ๊ณผ spawn ์ „ ์ค€๋น„(CSRG-U3) -CSRG-U3์€ `required`๋ฅผ ์‹คํ–‰ ์ง์ „๊นŒ์ง€ ๋™์ž‘ํ•˜๊ฒŒ ํ•œ๋‹ค. ์‹คํ–‰ ์†Œ์œ ์ž๋Š” ๊ด€๋ฆฌ ๋Œ€์ƒ ์‹คํ–‰๋งˆ๋‹ค DevGuard์˜ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฐ›๊ณ  ๊ทธ ์‹คํ–‰์˜ ์ผํšŒ์šฉ ์ค€๋น„๋ฅผ ๋ณด์œ ํ•œ๋‹ค. ์—ฌ์ „ํžˆ ์•„๋ฌด๊ฒƒ๋„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ด€๋ฆฌํ˜• ์‹คํ–‰(`BeginLaunch`, ๊ทธ permit๊ณผ carrier, launch helper)์€ CSRG-U4๋‹ค. +CSRG-U3์€ `required`๋ฅผ ์‹คํ–‰ ์ง์ „๊นŒ์ง€ ๋™์ž‘ํ•˜๊ฒŒ ํ•œ๋‹ค. ์‹คํ–‰ ์†Œ์œ ์ž๋Š” ๊ด€๋ฆฌ ๋Œ€์ƒ ์‹คํ–‰๋งˆ๋‹ค DevGuard์˜ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฐ›๊ณ  ๊ทธ ์‹คํ–‰์˜ ์ผํšŒ์šฉ ์ค€๋น„๋ฅผ ๋ณด์œ ํ•˜๋ฉฐ, ์ค€๋น„๋Š” ์•„๋ฌด๊ฒƒ๋„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ [๊ด€๋ฆฌํ˜• ์‹คํ–‰](#devguard-launch)(`BeginLaunch`, ๊ทธ permit๊ณผ carrier, launch helper)์€ CSRG-U4๋‹ค. **์ˆœ์„œ.** `--devguard register`๋กœ ์‹œ์ž‘ํ•œ gateway์—์„œ `required` workspace์˜ ์ƒˆ ์‹คํ–‰์€ ๋‹ค์Œ์„ ๊ฑฐ์นœ๋‹ค. @@ -146,7 +146,7 @@ CSRG-U3์€ `required`๋ฅผ ์‹คํ–‰ ์ง์ „๊นŒ์ง€ ๋™์ž‘ํ•˜๊ฒŒ ํ•œ๋‹ค. ์‹คํ–‰ ์†Œ์œ  **์ค€๋น„.** ๋„ค ๋‹จ๊ณ„๋ฅผ ๊ตฌ๋ถ„ํ•ด ๋ณด๊ด€ํ•œ๋‹ค. *์š”์ฒญ*ํ•œ ์–‘๊ณผ *ํ•„์ˆ˜* ์ตœ์†Œ ์ œ์–ด, DevGuard๊ฐ€ ๊ทธ์— ๋Œ€ํ•ด *์ง€์›*ํ•˜๋Š” ๊ฒƒ(scope์™€ ์ž์›๋ณ„ levelยทmethod), *์˜ˆ์•ฝ*ํ•œ ์–‘๊ณผ ์‹คํ–‰ ์—†์ด ์œ ์ง€ํ•˜๋Š” ๊ธฐ๊ฐ„, *์ ์šฉ*ํ•œ ๊ฒƒ์ด๋ฉฐ ๋งˆ์ง€๋ง‰์€ `not_launched`๋กœ ๋‚จ๋Š”๋‹ค. ๋ณต์ œํ•  ์ˆ˜ ์—†๊ณ  ์‹คํ–‰์ด๋‚˜ ์ทจ์†Œ๊ฐ€ ์†Œ๋น„ํ•˜๋ฏ€๋กœ ํ•œ ๋ฒˆ๋งŒ ์“ฐ์ธ๋‹ค. ๊ธฐํ•œ์€ DevGuard์˜ ์ค€๋น„ ์œ ์ง€ ๊ธฐ๊ฐ„(pin ๊ธฐ์ค€ 5์ดˆ)์ด๋ฉฐ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฌป๊ธฐ ์ „๋ถ€ํ„ฐ ์žฐ๋‹ค. ์‹คํ–‰ํ•  ๋•Œ ๋‹ค๋ฅธ ๋ช…๋ น, `process_id`, PTY ์„ ํƒ์„ ๋‚ด๋ฐ€๋ฉด ๊ฑฐ๋ถ€ํ•˜๊ณ  attempt๋ฅผ ์ทจ์†Œํ•œ๋‹ค. ๊ธฐํ•œ์ด ์ง€๋‚œ ๋’ค์— ๋‚ด๋ฐ€์–ด๋„ ๊ฐ™๋‹ค. ์ •๋ฆฌํ•˜์ง€ ์•Š๊ณ  ๋ฒ„๋ฆฐ ์ค€๋น„๋Š” ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์ทจ์†Œํ•œ๋‹ค. -**๊ด€๋ฆฌํ˜• ์‹คํ–‰์ด ์ƒ๊ธฐ๊ธฐ ์ „๊นŒ์ง€.** ์ค€๋น„์— ์„ฑ๊ณตํ•˜๋ฉด ์†Œ์œ ์ž๋Š” ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•˜๊ณ  ์‹คํ–‰์„ `MANAGED_LAUNCH_UNAVAILABLE`๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค. ๋Œ€์‹  ๋‹ค๋ฅธ ๊ฒฝ๋กœ๋กœ ์‹คํ–‰ํ•˜๋Š” ์ผ์€ ์—†๋‹ค. ๋ชจ๋“  ๊ฒฐ๊ณผ๋Š” ์•„๋ฌด๊ฒƒ๋„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š” ๊ฑฐ๋ถ€์ด๋ฉฐ workspace lease์™€ slot์„ ๋†“๊ณ , attempt ID, `process_id`, attempt์— ๋Œ€ํ•ด ์•„๋Š” ๊ฒƒ(`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other`, `unknown`)์„ ๋ฐํžŒ๋‹ค. +**launch helper๊ฐ€ ์—†์„ ๋•Œ.** ์ค€๋น„์— ์„ฑ๊ณตํ•ด๋„ launch helper๊ฐ€ ์—†๋Š” ์†Œ์œ ์ž๋Š” ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•˜๊ณ  ์‹คํ–‰์„ `MANAGED_LAUNCH_UNAVAILABLE`๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค. helper๊ฐ€ ์žˆ์œผ๋ฉด ์ค€๋น„๋ฅผ [์‹คํ–‰](#devguard-launch)ํ•œ๋‹ค. ๋Œ€์‹  ๋‹ค๋ฅธ ๊ฒฝ๋กœ๋กœ ์‹คํ–‰ํ•˜๋Š” ์ผ์€ ์—†๋‹ค. ๋ชจ๋“  ๊ฒฐ๊ณผ๋Š” ์•„๋ฌด๊ฒƒ๋„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š” ๊ฑฐ๋ถ€์ด๋ฉฐ workspace lease์™€ slot์„ ๋†“๊ณ , attempt ID, `process_id`, attempt์— ๋Œ€ํ•ด ์•„๋Š” ๊ฒƒ(`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other`, `unknown`)์„ ๋ฐํžŒ๋‹ค. | ๊ฒฐ๊ณผ | ๋„๊ตฌ ์˜ค๋ฅ˜ | | --- | --- | @@ -165,7 +165,67 @@ CSRG-U3์€ `required`๋ฅผ ์‹คํ–‰ ์ง์ „๊นŒ์ง€ ๋™์ž‘ํ•˜๊ฒŒ ํ•œ๋‹ค. ์‹คํ–‰ ์†Œ์œ  **ํ”Œ๋žซํผ.** macOS์—์„œ ์‹ค์ œ ํ˜ธ์ŠคํŠธ ์ฆ๊ฑฐ๋ฅผ ๊ฐ€์ง„ DevGuard ์„œ๋น„์Šค์™€ ํ•จ๊ป˜๋ผ๋ฉด ์‹คํ–‰์„ ํ—ˆ์šฉ๋ฐ›๊ณ  ์ทจ์†Œํ•œ๋‹ค. ์‹ค์ œ ์ฆ๊ฑฐ๊ฐ€ ์—†์œผ๋ฉด(DG-LINUX ์ „์˜ Linux) ์†Œ์œ ์ž๊ฐ€ ๋“ฑ๋กํ•  ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ๊ด€๋ฆฌ ๋Œ€์ƒ ์‹คํ–‰์€ ๋ชจ๋‘ `RESOURCE_AUTHORITY_UNAVAILABLE`์ด๋‹ค. -**ํ•˜์ง€ ์•Š๋Š” ์ผ.** `BeginLaunch`, permit, carrier, launch helper, ๊ด€์ฐฐ, reap, ํ•ด์ œ ํ†ตํ•ฉ๊ณผ pin ๋ณ€๊ฒฝ์€ ์—†๋‹ค. spawn, PTY, ์ถœ๋ ฅ, timeout, ์ข…๋ฃŒ, reap, lifecycle์€ CodeSpace์— ๋‚จ๋Š”๋‹ค. +**ํ•˜์ง€ ์•Š๋Š” ์ผ.** ์ค€๋น„๋Š” `BeginLaunch`๋ฅผ ๋ณด๋‚ด์ง€ ์•Š๊ณ  ์•„๋ฌด๊ฒƒ๋„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ์‹คํ–‰์€ CSRG-U4๊ฐ€ ํ•œ๋‹ค. ๊ด€์ฐฐ, reap, ํ•ด์ œ ํ†ตํ•ฉ๊ณผ pin ๋ณ€๊ฒฝ์€ ์—†๋‹ค. spawn, PTY, ์ถœ๋ ฅ, timeout, ์ข…๋ฃŒ, reap, lifecycle์€ CodeSpace์— ๋‚จ๋Š”๋‹ค. + + + +## ๊ด€๋ฆฌํ˜• ์‹คํ–‰(CSRG-U4) + +CSRG-U4๋Š” ์ค€๋น„๋œ ์‹คํ–‰์„ DevGuard ์•„๋ž˜์—์„œ ํ•œ ๋ฒˆ ์‹คํ–‰ํ•œ๋‹ค. `required`์ด๋ฉด ์ด์ œ ๋ช…๋ น์ด DevGuard์˜ launch helper์™€ CodeSpace ์ž์‹ ์˜ pipeยทPTY ์‹คํ–‰ ๊ฒฝ๋กœ๋ฅผ ๊ฑฐ์ณ ์‹คํ–‰๋œ๋‹ค. + +- **์‹คํ–‰.** ์ด์ „์ฒ˜๋Ÿผ `--devguard register`์— `--devguard-launch-helper PATH`(`CODESPACE_DEVGUARD_LAUNCH_HELPER`)๋ฅผ ๋”ํ•œ๋‹ค. socket์„ ์ œ๊ณตํ•˜๋Š” DevGuard release์˜ `devguard-launch` ์ ˆ๋Œ€ ๊ฒฝ๋กœ์ด๋ฉฐ, `devguard doctor`๊ฐ€ ์•Œ๋ ค ์ค€๋‹ค. UDS ๋ชจ๋“œ์—์„œ๋Š” gateway๊ฐ€ ์ž์‹ ์ด ์‹œ์ž‘ํ•˜๋Š” worker์—๊ฒŒ ์ด ๊ฒฝ๋กœ๋ฅผ ๋„˜๊ธด๋‹ค. ์ด ์„ค์ •์ด ์—†์œผ๋ฉด ๊ด€๋ฆฌ ๋Œ€์ƒ ์‹คํ–‰์€ CSRG-U3์ฒ˜๋Ÿผ ์‹คํ–‰ ํ—ˆ์šฉ์„ ๋ฐ›์€ ๋’ค `MANAGED_LAUNCH_UNAVAILABLE`๋กœ ๊ฑฐ๋ถ€๋œ๋‹ค. + +**์ˆœ์„œ.** CSRG-U3์˜ [์ค€๋น„](#devguard-admission) ๋‹ค์Œ์— ์‹คํ–‰ ์†Œ์œ ์ž๋Š” ๋‹ค์Œ์„ ํ•œ๋‹ค. + +1. **๋ฌด์—‡์„ ํ™•์ •ํ•˜๊ธฐ ์ „์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•œ๋‹ค.** ์ค€๋น„๋œ ๋ช…๋ น๊ณผ ๊ทธ ๊ธฐํ•œ, helper(๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ”๊ฟ” ๋†“์„ ์ˆ˜ ์—†๋Š” ์ผ๋ฐ˜ ์‹คํ–‰ ํŒŒ์ผ์ด์–ด์•ผ ํ•œ๋‹ค. ์ด ์‚ฌ์šฉ์ž๋‚˜ root๊ฐ€ ์†Œ์œ ํ•˜๊ณ  group๊ณผ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ์“ธ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ฒฝ๋กœ์˜ ๋””๋ ‰ํ„ฐ๋ฆฌ๋„ ๊ทธ๋ž˜์•ผ ํ•œ๋‹ค), ๊ทธ๋ฆฌ๊ณ  ์‹คํ–‰ ๊ฒฝ๋กœ๊ฐ€ ์ฐพ์„ ๋ฐฉ์‹๋Œ€๋กœ ์‹คํ–‰์˜ `PATH`์—์„œ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ์ฐพ์€ ํ”„๋กœ๊ทธ๋žจ์ด๋‹ค. ์—ฌ๊ธฐ์„œ ์‹คํŒจํ•˜๋ฉด ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•œ๋‹ค. +2. **`BeginLaunch`๋ฅผ ํ•œ ๋ฒˆ ๋ณด๋‚ธ๋‹ค.** ๋“ฑ๋ก๋œ ์†Œ์œ ์ž์˜ ์ƒํ•œ ์žˆ๋Š” ์„ธ์…˜(`Register` ํ›„ `BeginLaunch`)์—์„œ ๋ณด๋‚ธ๋‹ค. ์ฒซ ๋‹ต๋งŒ attempt์˜ ์ผํšŒ์šฉ permit์„ ๋‹ด์œผ๋ฉฐ, ์ด ์†Œ์œ ์ž๊ฐ€ ํ™•์ •ํ•œ ์ด attemptยท์˜๋ฏธ์˜ ๊ธฐ๋ก๊ณผ ํ•จ๊ป˜ ์˜จ permit๋งŒ ๋ฐ›์•„๋“ค์ธ๋‹ค. +3. **์ž์‹ ์˜ ์‹คํ–‰ ๊ฒฝ๋กœ๋กœ helper๋ฅผ ์‹œ์ž‘ํ•œ๋‹ค.** ์ž์‹ ์˜ ์ง๊ณ„ ์ž์‹์œผ๋กœ ์‹œ์ž‘ํ•˜๋ฉฐ ์ค€๋น„๊ฐ€ ์žก์•„ ๋‘” slot์„ ์ฑ„์šด๋‹ค. ํ˜ธ์ถœ์€ DevGuard์˜ `helper_command`๊ฐ€ ๋งŒ๋“ ๋‹ค. secret์ด ์—†๋Š” helper ์ธ์ž์™€ ๋น„๊ณต๊ฐœ descriptor ๋‘ ๊ฐœ, ์ฆ‰ permit carrier(permit์„ ๋‹ด๊ณ  ์“ฐ๋Š” ์ชฝ์„ ๋‹ซ์€ socket pair)์™€ helper transcript์˜ ์“ฐ๋Š” ์ชฝ(pipe)์ด๋‹ค. ์ด ๋‘˜์€ helper๋งŒ ๋ฐ›๋Š”๋‹ค. +4. **transcript๋ฅผ ๋๊นŒ์ง€ ์ฝ๋Š”๋‹ค.** helper๋Š” ์ž์‹ ์˜ process group์„ ์ด๋Œ๊ณ  grant๋ฅผ ์ œ์‹œํ•œ๋‹ค. DevGuard๋Š” ๊ทธ group์„ attempt์˜ scope๋กœ ๋ฌถ๊ณ  ์ •์ฑ…์„ ์ ์šฉํ•ด ๋‹ค์‹œ ์ฝ์€ ๋’ค ์‹คํ–‰์„ ์Šน์ธํ•œ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด helper๊ฐ€ READY๋ฅผ ๋ณด๊ณ ํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉฐ, ํ”„๋กœ๊ทธ๋žจ์€ helper์˜ PID๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค. READY ๋’ค transcript๊ฐ€ ๋๋‚˜๋ฉด ๋ช…๋ น์ด ์‹œ์ž‘๋œ ๊ฒƒ์ด๋‹ค. + +DevGuard๋Š” helper์˜ ๋ถ€๋ชจ๊ฐ€ ๋“ฑ๋ก๋œ ์†Œ์œ ์ž์ด๊ธฐ๋ฅผ ์š”๊ตฌํ•˜๋ฏ€๋กœ ์‹คํ–‰์€ ์†Œ์œ ์ž๊ฐ€ ํ•œ๋‹ค. `in-process` ๋ชจ๋“œ์—์„œ๋Š” gateway๊ฐ€, `uds` ๋ชจ๋“œ์—์„œ๋Š” gateway๊ฐ€ ์‹œ์ž‘ํ•œ worker๊ฐ€ `launch` ์š”์ฒญ ํ•˜๋‚˜๋ฅผ ๋ฐ›์•„ ์‹คํ–‰ํ•œ๋‹ค. worker๋Š” ๋“ฑ๋กํ•˜๊ณ  helper๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ `Hello`์— `launch`๋ฅผ ๋ฐํžˆ๋ฉฐ, ์ด ์š”์ฒญ์€ ๋‹ค์‹œ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค. + +**ํ”„๋กœ์„ธ์Šค.** spawn ๋’ค์—๋Š” ๋‹ค๋ฅธ CodeSpace ํ”„๋กœ์„ธ์Šค์™€ ๊ฐ™๋‹ค. ๊ฐ™์€ slot, ์ถœ๋ ฅ, stdin, `process_resize`, timeout, `terminate_process`, workspace lease, reap์„ ์ค€๋น„๋œ `process_id`๋กœ ์“ด๋‹ค. ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š” ์‹คํ–‰๊ณผ๋Š” ์„ธ ๊ฐ€์ง€๊ฐ€ ๋‹ค๋ฅด๋‹ค. + +- ๋ช…๋ น์€ ์ฐพ์•„๋‚ธ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ `argv[0]`๋กœ ๋ฐ›์•„ ์‹คํ–‰๋œ๋‹ค. +- pipe์—์„œ๋Š” helper ๋•Œ๋ฌธ์— ์ž์‹ ์˜ process group์„ ์ด๋ˆ๋‹ค. PTY์—์„œ๋Š” ์ด์ „์ฒ˜๋Ÿผ ์ž์‹ ์˜ session์„ ์ด๋ˆ๋‹ค. +- PTY์—์„œ signal๋กœ ์ฃฝ์œผ๋ฉด ๊ณ ์ •๋œ Codex PTY์˜ descriptor ์œ ์ง€ ๊ฒฝ๋กœ๊ฐ€ ๋ณด๊ณ ํ•˜๋Š” ๋Œ€๋กœ 1์ด ์•„๋‹ˆ๋ผ 128์— signal ๋ฒˆํ˜ธ๋ฅผ ๋”ํ•œ exit code๋กœ ๋ณด๊ณ ํ•œ๋‹ค. + +**๊ฒฐ๊ณผ.** `exec_command`๋Š” transcript๊ฐ€ ๋๋‚˜๊ฑฐ๋‚˜ 10์ดˆ๊ฐ€ ์ง€๋‚˜๋ฉด ๋Œ์•„์˜จ๋‹ค. + +| transcript | ๊ฒฐ๊ณผ | +| --- | --- | +| READY ๋’ค ๋๋‚จ | `dispatch_status: confirmed` | +| ์ œ๋•Œ ๋๋‚˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Œ | `dispatch_status: unknown`. ํ”„๋กœ์„ธ์Šค๋Š” ์–ด๋А ์ชฝ์ด๋“  CodeSpace์˜ ๊ฒƒ์ด๋ฉฐ, ๋ช…๋ น์ด ์‹œ์ž‘๋˜์—ˆ๋Š”์ง€๋Š” ์•Œ ์ˆ˜ ์—†๋‹ค | +| READY ๋’ค `exec_failed` | `PROCESS_SPAWN_FAILED`. helper๋Š” 126์ด๋‚˜ 127๋กœ ๋๋‚˜๊ณ , DevGuard๋Š” scope๋ฅผ ํ†ตํ•ด attempt๋ฅผ ์ •๋ฆฌํ•œ๋‹ค | +| READY ์ „์— ๊ฑฐ๋ถ€, ์‹คํŒจ, ์ข…๋ฃŒ | ๋ช…๋ น์€ ์‹คํ–‰๋˜์ง€ ์•Š์•˜๋‹ค. CodeSpace๊ฐ€ helper๋ฅผ reapํ•œ ๋’ค ์†Œ์œ ์ž๋Š” helper๋ฅผ ๊ฐ–๊ณ  ์žˆ์ง€ ์•Š๋‹ค๊ณ  ๋ณด๊ณ ํ•œ๋‹ค(`AbandonLaunch`) | + +๋ชจ๋“  ๊ฑฐ๋ถ€๋Š” attempt ID, `process_id`, attempt์— ๋Œ€ํ•ด ์•„๋Š” ๊ฒƒ์„ ๋ฐํžŒ๋‹ค. helper ํ”„๋กœ์„ธ์Šค๊ฐ€ ์—†์—ˆ์œผ๋ฉด "nothing was started"๋กœ, ์žˆ์—ˆ์œผ๋ฉด "the command was not started"๋กœ ๋๋‚œ๋‹ค. ์‹คํ–‰ ๋‹จ๊ณ„๋Š” attempt ์ƒํƒœ ์„ธ ๊ฐ€์ง€๋ฅผ ๋”ํ•œ๋‹ค. `released`(DevGuard๊ฐ€ ์•„๋ฌด๋„ ๊ฐ€์ ธ๊ฐ€์ง€ ์•Š์€ grant๋ฅผ ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ ๊ฒƒ์œผ๋กœ ํ•ด์ œํ•จ), `launched`(helper๊ฐ€ ๊ฐ€์ ธ๊ฐ”์œผ๋ฉฐ DevGuard๊ฐ€ scope์˜ ๋์„ ๊ด€์ฐฐํ•  ๋•Œ๊นŒ์ง€ ์œ ์ง€ํ•จ), `scope_ended`๋‹ค. + +| ๊ฒฐ๊ณผ | ๋„๊ตฌ ์˜ค๋ฅ˜ | +| --- | --- | +| launch helper๊ฐ€ ์—†๊ฑฐ๋‚˜ ์“ธ ์ˆ˜ ์—†์Œ | `MANAGED_LAUNCH_UNAVAILABLE` | +| ํ”„๋กœ๊ทธ๋žจ์ด `PATH`์— ์žˆ๋Š” ์‹คํ–‰ ํŒŒ์ผ์ด ์•„๋‹ˆ๊ฑฐ๋‚˜, Linux ๋ช…๋ น sandbox๊ฐ€ ํ•„์š”ํ•œ `Enabled` network | `PROCESS_SPAWN_FAILED` | +| Linux ๋ช…๋ น sandbox๊ฐ€ ๋ช…๋ น์„ ๊ฐ์‹ธ๊ฒŒ ๋จ | `RESOURCE_POLICY_UNSUPPORTED` | +| DevGuard๊ฐ€ `BeginLaunch`๋ฅผ ๊ฑฐ๋ถ€ํ–ˆ๊ฑฐ๋‚˜ helper๋ฅผ ์Šน์ธํ•˜์ง€ ์•Š์Œ | `resource_unavailable`์ด๋ฉด `RESOURCE_UNAVAILABLE`, `resource_policy_unsupported`์ด๋ฉด `RESOURCE_POLICY_UNSUPPORTED`, ๊ทธ ๋ฐ–์—๋Š” `RESOURCE_AUTHORITY_UNAVAILABLE` | +| `BeginLaunch`์— ๋‹ต์ด ์˜ค์ง€ ์•Š์Œ | attempt๋ฅผ ์•Œ ์ˆ˜ ์—†์œผ๋ฉด `ADMISSION_UNKNOWN`, ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด `RESOURCE_AUTHORITY_UNAVAILABLE` | +| helper๊ฐ€ grant๋ฅผ ์ œ์‹œํ•˜๊ธฐ ์ „์— ์‹คํŒจํ•จ | `RESOURCE_AUTHORITY_UNAVAILABLE` | +| helper๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†์—ˆ๊ฑฐ๋‚˜, ๋ณด๊ณ  ์—†์ด READY ์ „์— ๋๋‚จ | `PROCESS_SPAWN_FAILED` | + +**์‹คํ–‰ ํ•œ ๋ฒˆ, ์žฌ์‹คํ–‰ ์—†์Œ.** `BeginLaunch`๋Š” attempt๋งˆ๋‹ค ์ตœ๋Œ€ ํ•œ ๋ฒˆ ๋ณด๋‚ธ๋‹ค. timeout, ์žƒ์–ด๋ฒ„๋ฆฐ ๋‹ต, EOF, permit์ด ์—†๊ฑฐ๋‚˜ ๋‹ค๋ฅธ ๊ฒƒ์— ๋Œ€ํ•œ ๋‹ต์€ ๊ฑฐ๋ถ€๊ฐ€ ์•„๋‹ˆ๋‹ค. ์†Œ์œ ์ž๋Š” attempt๋ฅผ ํ•œ ๋ฒˆ ์กฐํšŒํ•œ๋‹ค. ์•„์ง ์ค€๋น„ ์ƒํƒœ๋ฉด ์‹คํ–‰์ด ํ™•์ •๋˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ ์ทจ์†Œํ•œ๋‹ค. ํ™•์ •๋˜์—ˆ๋Š”๋ฐ ์•„๋ฌด๋„ ๊ฐ€์ ธ๊ฐ€์ง€ ์•Š์•˜์œผ๋ฉด permit์ด ๋„์ฐฉํ•˜์ง€ ์•Š์•˜๊ณ  helper๋„ ์—†์œผ๋ฏ€๋กœ ์†Œ์œ ์ž๊ฐ€ ์ด๋ฅผ ๋ณด๊ณ ํ•˜๋ฉฐ(`AbandonLaunch`, `grant_not_received`), DevGuard๋Š” ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ ๊ฒƒ์œผ๋กœ ํ•ด์ œํ•œ๋‹ค. ๊ทธ ๋ฐ–์—๋Š” ๋ฐœ๊ฒฌํ•œ ๋Œ€๋กœ, ๋˜๋Š” `unknown`์œผ๋กœ ๋ณด๊ณ ํ•˜๋ฉฐ ์–ด๋–ค ID๋กœ๋„ ๋‹ค์‹œ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. ์‹œ์ž‘ํ•˜์ง€ ๋ชปํ•œ helper๋„ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ(`spawn_failed`), READY ์ „์— ๋๋‚œ helper๋Š” reapํ•œ ๋’ค์—(`helper_exited`) ๋ณด๊ณ ํ•œ๋‹ค. helper๊ฐ€ ๊ฐ€์ ธ๊ฐ„ grant๋Š” ๋ณด๊ณ ๋กœ ํ•ด์ œ๋˜์ง€ ์•Š์œผ๋ฉฐ scope์˜ ๋์œผ๋กœ๋งŒ ํ•ด์ œ๋œ๋‹ค. `uds` ๋ชจ๋“œ์—์„œ worker๊ฐ€ ๋ฐ›์•˜์„ ์ˆ˜ ์žˆ์œผ๋‚˜ ๋‹ตํ•˜์ง€ ์•Š์€ `launch` ์š”์ฒญ์€ ๊ทธ `process_id`๋กœ `dispatch_status: unknown`์„ ๋Œ๋ ค์ฃผ๊ณ , ๋– ๋‚˜์ง€๋„ ๋ชปํ•œ ์š”์ฒญ์€ `RESOURCE_AUTHORITY_UNAVAILABLE`(`owner_unreachable`)์ด๋‹ค. + +**carrier์™€ ์ž์‹ ํ”„๋กœ์„ธ์Šค.** permit carrier์™€ transcript๋Š” helper์—๊ฒŒ๋งŒ ๊ฐ€๊ณ  ๋‹ค๋ฅธ ์ž์‹์—๊ฒŒ๋Š” ๊ฐ€์ง€ ์•Š๋Š”๋‹ค. + +- pipe์—์„œ๋Š” helper๋ฅผ ์‹œ์ž‘ํ•  ๋•Œ ์ž์‹์—์„œ ๋‘ descriptor๋ฅผ ๋บ€ 2 ์ดˆ๊ณผ descriptor๋ฅผ ๋ชจ๋‘ close-on-exec๋กœ ํ‘œ์‹œํ•˜๊ณ , ๊ทธ ๋‘˜์˜ close-on-exec๋Š” ์ž์‹์—์„œ๋งŒ ํ•ด์ œํ•œ๋‹ค. ๋ชจ๋“  CodeSpace ์‹คํ–‰ ๊ฒฝ๋กœ๊ฐ€ ์“ฐ๋Š” [#79](https://github.com/novelKR/CodeSpace/issues/79) ์ œ์™ธ์™€ ๊ฐ™์€ ๋ฐฉ์‹์ด๋ฉฐ, ๋‘˜ ์—†์ด helper๋ฅผ ์‹œ์ž‘ํ•˜๋А๋‹ˆ ์‹œ์ž‘์„ ์‹คํŒจ์‹œํ‚จ๋‹ค. +- PTY์—์„œ๋Š” ๊ณ ์ •๋œ Codex PTY๊ฐ€ ์ด๋ฏธ ์ƒ์† ๊ฐ€๋Šฅํ•œ descriptor๋งŒ ๋„˜๊ธฐ๋ฏ€๋กœ(`inherited_fds`) ๋‘ descriptor๋Š” ๊ทธ ์‹œ์ž‘ ์ง์ „๋ถ€ํ„ฐ ๋๋‚  ๋•Œ๊นŒ์ง€ ์†Œ์œ ์ž ํ”„๋กœ์„ธ์Šค์—์„œ ์ƒ์† ๊ฐ€๋Šฅํ•˜๋‹ค. ๊ทธ ํ”„๋กœ์„ธ์Šค์˜ ๋‹ค๋ฅธ ์‹คํ–‰ ๊ฒฝ๋กœ(pipe, PTY, ํŒจ์น˜์™€ sandbox helper, worker)๋Š” ๋ชจ๋‘ ๊ด€๋ จ ์—†๋Š” descriptor๋ฅผ ์ž์‹์—๊ฒŒ์„œ ์ œ์™ธํ•˜๋ฏ€๋กœ ๊ทธ๋™์•ˆ ์ด ๋‘˜์„ ๋ฐ›๋Š” ์ž์‹์€ ์—†๋‹ค. Codex PTY์˜ ์ž์‹๋„ ๋‹ค๋ฅธ ์ƒ์† ๊ฐ€๋Šฅ descriptor๋ฅผ ์Šค์Šค๋กœ ๋‹ซ๋Š”๋‹ค. +- ์†Œ์œ ์ž์˜ ์‚ฌ๋ณธ์€ ์‹œ์ž‘์ด ๋๋‚˜์ž๋งˆ์ž ๋‹ซํžŒ๋‹ค. helper๋Š” permit์„ ์†Œ๋น„ํ•ด ๋‹ซ๊ณ , transcript๋ฅผ close-on-exec๋กœ ํ‘œ์‹œํ•˜๋ฉฐ, ์ž์‹ ์˜ authority ์„ธ์…˜์„ ์‹คํ–‰ ํŒŒ์ผ ์‹œ์ž‘ ์ „์— ๋‹ซ๋Š”๋‹ค. ๊ทธ๋ž˜์„œ ์‹คํ–‰ ํŒŒ์ผ์€ ์ด ์ค‘ ์–ด๋А ๊ฒƒ๋„ ๊ฐ–์ง€ ์•Š๋Š”๋‹ค. + +ํ…Œ์ŠคํŠธ๋Š” ๊ฐ๊ฐ์„ ๋Œ€์กฐ๊ตฐ๊ณผ ํ•จ๊ป˜ ํ™•์ธํ•œ๋‹ค. ์ผ๋ถ€๋Ÿฌ ์ƒ์† ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋‘” descriptor, ๋ฐ›์€ ๊ฒƒ์„ ์ •ํ™•ํžˆ ๋ณด๊ณ ํ•˜๋Š” ๋Œ€์—ญ helper, PTY ์ฐฝ ์•ˆ์—์„œ ์‹œ์ž‘ํ•œ ๋ณดํ˜ธ ์—†๋Š” ์ž์‹(๋‘ descriptor๋ฅผ ๋ชจ๋‘ ๊ฐ€์ง€๋ฉฐ ๋ณดํ˜ธํ•œ ์ž์‹์€ ํ•˜๋‚˜๋„ ๊ฐ–์ง€ ์•Š์Œ), ๊ทธ๋ฆฌ๊ณ  DevGuard์˜ fixture authority์™€ launch helper๋ฅผ ์ƒ๋Œ€๋กœ ํ•œ gateway์—์„œ ์ผ๋ฐ˜ pipeยทPTY ์ž์‹๊ณผ ๋™์‹œ์— ๋„๋Š” ๊ด€๋ฆฌํ˜• ์‹คํ–‰์ด๋‹ค. + +**ํšŒ๊ณ„.** ์‹คํ–‰๋œ ์‹คํ–‰์˜ ํšŒ๊ณ„์—๋Š” READY ๋’ค DevGuard ๊ธฐ๋ก์—์„œ ์ฝ์€ ์ž์›๋ณ„ ์ ์šฉ ๋‚ด์šฉ๊ณผ, helper์˜ PID์ธ scope์˜ root๊ฐ€ ๋”ํ•ด์ง„๋‹ค. `workspace_info.resource_authority.governs_execution`์€ ์‹คํ–‰ ์†Œ์œ ์ž๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ๊ณ  launch helper๊ฐ€ ์žˆ์„ ๋•Œ `true`๋‹ค. + +**์ง€์›.** ์‹ค์ œ ํ˜ธ์ŠคํŠธ ์ฆ๊ฑฐ๋ฅผ ๊ฐ€์ง„ DevGuard ์„œ๋น„์Šค๊ฐ€ ์žˆ๋Š” macOS์—์„œ `in-process`์™€ `--runtime-bin`์„ ์“ด `uds`๋ฅผ ๊ฐ๊ฐ pipe์™€ PTY๋กœ ์ง€์›ํ•œ๋‹ค. ์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒƒ: ์†Œ์œ ์ž๊ฐ€ ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋Š” `--runner-socket`์„ ์“ด `uds`์™€ DG-LINUX ์ „์˜ Linux, ๋ฌด์—‡์„ ํ™•์ •ํ•˜๊ธฐ ์ „์— ๊ฑฐ๋ถ€๋˜๋Š” Linux ๋ช…๋ น sandbox์™€ `Enabled` network. + +**์•„์ง ํ•˜์ง€ ์•Š๋Š” ์ผ(CSRG-U5).** CodeSpace๋Š” DevGuard์— scope๋ฅผ ๊ด€์ฐฐํ•˜๊ฑฐ๋‚˜ signalํ•˜๊ฒŒ ํ•˜์ง€ ์•Š๊ณ  ์ด์ „์ฒ˜๋Ÿผ ํ”„๋กœ์„ธ์Šค๋ฅผ reapํ•˜๊ณ  ์ข…๋ฃŒํ•œ๋‹ค. pipe์—์„œ `terminate_process`์™€ timeout์€ root๋งŒ ์ฃฝ์ธ๋‹ค. DevGuard๋Š” ๋ชจ๋“  ๊ตฌ์„ฑ์›์ด root์™€ ํ•จ๊ป˜ ๋๋‚œ scope๋ฅผ ํ•ด์ œํ•œ๋‹ค. root๋ณด๋‹ค ์˜ค๋ž˜ ์‚ฌ๋Š” ์ž์†์€ ์‹คํ–‰๋˜๋Š” ๋™์•ˆ attempt๋ฅผ ๊ณ„์† ์ฐจ์ง€ํ•˜๊ฒŒ ํ•˜๊ณ , DevGuard๊ฐ€ ๊ด€์ฐฐํ•˜๊ธฐ ์ „์— CodeSpace์˜ reap ๋’ค์— ๋‚จ์€ ์ž์†์€ attempt๋ฅผ suspect๋กœ ๋‚จ๊ธด๋‹ค. ์Šน์ธ dispatch ์ƒํƒœ, ๋ชจ๋“  ์ข…๋ฃŒ ๊ฒฝ๋กœ์˜ ํ•ด์ œ, qualification์€ ์ฃผ์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค. ## CS-RG ์ž‘์—… ์ˆœ์„œ diff --git a/docs/ko/upstream-lock.md b/docs/ko/upstream-lock.md index 4f1078e..1eecd54 100644 --- a/docs/ko/upstream-lock.md +++ b/docs/ko/upstream-lock.md @@ -37,6 +37,7 @@ gateway([CSRG-U1](devguard-integration.md#devguard-status-connection))์™€ UDS wo | ์ปค๋ฐ‹ | `6e7e065a0dd5b3876dc7f4546ab76069489937d1`. [DevGuard PR #27](https://github.com/novelKR/DevGuard/pull/27) ๋ณ‘ํ•ฉ ํ›„์˜ DevGuard `main` | | ์†Œ์Šค | ๊ทธ `rev`์˜ Cargo Git ์˜์กด์„ฑ | | crate | ์ œํ’ˆ์—๋Š” `devguard-client`์™€ `devguard-contract`. `test-fixtures`๋ฅผ ์ผ  `devguard-daemon`์€ ์–ด๋Œ‘ํ„ฐ ํ…Œ์ŠคํŠธ์—์„œ๋งŒ ์‚ฌ์šฉ | +| launch helper | `devguard-launch`๋Š” ๋งํฌํ•˜์ง€ ์•Š์Œ. ์šด์˜์ž๋Š” socket์„ ์ œ๊ณตํ•˜๋Š” DevGuard release์˜ helper๋ฅผ ์ง€์ •ํ•˜๊ณ (`--devguard-launch-helper`, [CSRG-U4](devguard-integration.md#devguard-launch)), launch ํ…Œ์ŠคํŠธ๋Š” DevGuard ์ž์ฒด lockfile๋กœ ์ด ์ปค๋ฐ‹์—์„œ ๋นŒ๋“œํ•จ | | ์‚ฌ์šฉํ•˜๋Š” ๊ตฌ์„ฑ ์š”์†Œ | `crates/devguard`(`codespace-devguard`). `codespace-server`, `codespace-runner`, `codespace-codex-runtime`์€ ๊ฐ์ž์˜ `devguard` feature๋ฅผ ์ผฐ์„ ๋•Œ๋งŒ ๋งํฌ | | ๊ฒ€์‚ฌ | `scripts/upstream_dependencies.py`(`DEVGUARD_SOURCE`) | diff --git a/docs/ko/upstream-update.md b/docs/ko/upstream-update.md index 582a03f..2eaf652 100644 --- a/docs/ko/upstream-update.md +++ b/docs/ko/upstream-update.md @@ -156,3 +156,11 @@ manifest์™€ ์†Œ์Šค๋ฅผ core์™€ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค. ์ด ์–ด๋Œ‘ํ„ฐ graph(`codex-runtime+devguard`)๋„ root์™€ ๊ฐ™์ด ๊ฒ€์‚ฌํ•˜๋ฉฐ, Codex ์–ด๋Œ‘ํ„ฐ leg๋Š” ์ด feature๋กœ worker๋ฅผ ๋นŒ๋“œยทlintยทํ…Œ์ŠคํŠธํ•ฉ๋‹ˆ๋‹ค. feature๊ฐ€ ์—†์œผ๋ฉด ๋ชจ๋“  ์ œํ’ˆ graph๋Š” ๋ฐ”๋€Œ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +*2026-10-09 ์ถ”๊ฐ€, CSRG-U4:* DevGuard์˜ launch helper์ธ `devguard-launch`๋Š” ๋งํฌํ•˜์ง€ +์•Š์Šต๋‹ˆ๋‹ค. ์šด์˜์—์„œ๋Š” ์‹คํ–‰ ์†Œ์œ ์ž๊ฐ€ socket์„ ์ œ๊ณตํ•˜๋Š” DevGuard release์˜ helper๋ฅผ +`--devguard-launch-helper`๋กœ ์ง€์ •ํ•ด ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. launch ํ…Œ์ŠคํŠธ๋ฅผ ๋Œ๋ฆฌ๋Š” ๊ฒ€์ฆ ๋‹จ๊ณ„๋Š” +DevGuard client ๊ณ ์ • ๋ฒ„์ „์—์„œ DevGuard ์ž์ฒด lockfile๋กœ ์ด๋ฅผ ๋นŒ๋“œํ•˜๊ณ  +(`cargo install --locked --git ... --rev`, `scripts/validate-upstream.py`์˜ +`devguard_binaries()`), `CODESPACE_DEVGUARD_LAUNCH_BIN`์œผ๋กœ ํ…Œ์ŠคํŠธ์— ์•Œ๋ฆฝ๋‹ˆ๋‹ค. ์ œํ’ˆ +graph๋Š” ๋ฐ”๋€Œ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. diff --git a/docs/translations.json b/docs/translations.json index 2e81ca9..555ed85 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -45,11 +45,13 @@ "devguard-admission", "devguard-integration", "devguard-integration-roadmap", + "devguard-launch", "devguard-owner-registration", "devguard-status-connection", "devguard-๊ฒฐํ•ฉ-๋กœ๋“œ๋งต", "execution-owner-registration-csrg-u2", "initial-gateway-recovery-scope", + "managed-launch-csrg-u4", "minimum-adoption-conditions", "plan-and-evidence-references", "prerequisites-and-priority", @@ -60,6 +62,7 @@ "resource-plan-evidence", "status-only-connection-csrg-u1", "๊ณ„ํš๊ณผ-๊ฒ€์ฆ-๊ทผ๊ฑฐ", + "๊ด€๋ฆฌํ˜•-์‹คํ–‰csrg-u4", "์ƒํƒœ-์ „์šฉ-์—ฐ๊ฒฐcsrg-u1", "์„ ํ–‰-์กฐ๊ฑด๊ณผ-์šฐ์„ ์ˆœ์œ„", "์„ค๊ณ„-๊ฐœ์ •-1-์ฐธ์กฐ", @@ -69,8 +72,8 @@ "์ตœ์†Œ-๋„์ž…-์กฐ๊ฑด", "์ตœ์ดˆ-gateway-๋ณต๊ตฌ-๋ฒ”์œ„" ], - "source_sha256": "b3558cad42e553b838b8f92876479d1d34ff7e0661d91a88c95309f789e49424", - "translation_sha256": "1823e184cf2b97e4e56cf97b7d799b7853749d044dc5d56f7cdd37852e5309f7" + "source_sha256": "dbd0ae11c2ded4c9304c6ece9b69669219ce0b9959ba41216d7dbf3372b0efdd", + "translation_sha256": "c9342e6fb72b077da2ebab4fe42d647ecb9d962288eeda2e0904109a9f3adc2e" }, { "id": "overview", @@ -640,8 +643,8 @@ "ํŒŒ์ผ-๋ณต์‚ฌ-๋ฒค๋”๊ฐ€-๊ธˆ์ง€์ธ-์ด์œ ", "ํ™•์ธ๊ณผ-๊ฐฑ์‹ " ], - "source_sha256": "1ce93b9c0c1ea970853a1c474032145b90ec8332bf6151522a35b6cc3f6c5619", - "translation_sha256": "b6d92f70f012e2eacae907abdc2995e06a8c23192e946c358fba885cc37d5b21" + "source_sha256": "bcc3fe556188b33bddf52ffa3964639f34ec68f96d69f87995aad9c92bce04f2", + "translation_sha256": "203d196d391c41b436b9fb561082db7175bb0675c930addea0cd873eefc0530c" }, { "id": "upstream-update", @@ -676,8 +679,8 @@ "ํ–ฅํ›„-crate์™€-๋ฐฑ์—”๋“œ์˜-๊ฒฝ๊ณ„", "ํ›„๋ณด-๊ฒ€ํ† " ], - "source_sha256": "26fd17e60a1bfcbddeba5fe7b09e163fbdcdf21fcc22e337050e911f27aae30c", - "translation_sha256": "9098e1859db66691422e4b941b04c8c030e221baed00120f50ce578210f28c4d" + "source_sha256": "b9dc9fdb4f6634b9f149540513dbbb9ae72d490a0ab02adc579d1092ad689f56", + "translation_sha256": "6297a87a070817627e236efd49f7999034f84018d23db198880d2ef8c92e88f7" }, { "id": "documentation", diff --git a/docs/upstream-lock.md b/docs/upstream-lock.md index 03cf383..81f11cd 100644 --- a/docs/upstream-lock.md +++ b/docs/upstream-lock.md @@ -35,6 +35,7 @@ The opt-in `devguard` features of the gateway ([CSRG-U1](devguard-integration.md | Commit | `6e7e065a0dd5b3876dc7f4546ab76069489937d1`, DevGuard `main` after [DevGuard PR #27](https://github.com/novelKR/DevGuard/pull/27) | | Source | Cargo Git dependency at that `rev` | | Crates | `devguard-client` and `devguard-contract` in the product; `devguard-daemon` with `test-fixtures` only in the adapter's tests | +| Launch helper | `devguard-launch` is never linked. Operators name the helper of the DevGuard release that serves the socket (`--devguard-launch-helper`, [CSRG-U4](devguard-integration.md#devguard-launch)); the launch tests build it from this commit with DevGuard's own lockfile | | Consumer | `crates/devguard` (`codespace-devguard`), linked by `codespace-server`, `codespace-runner` and `codespace-codex-runtime` only with their `devguard` features | | Check | `scripts/upstream_dependencies.py` (`DEVGUARD_SOURCE`) | diff --git a/docs/upstream-update.md b/docs/upstream-update.md index a380235..d35edcf 100644 --- a/docs/upstream-update.md +++ b/docs/upstream-update.md @@ -160,3 +160,11 @@ features; the gateway's feature turns on the runner's. The worker's graph with its feature on (`codex-runtime+devguard`) is checked like the root's, and the Codex adapter legs build, lint and test the worker with it. Without the features every product graph is unchanged. + +*Added 2026-10-09, CSRG-U4:* DevGuard's launch helper, `devguard-launch`, is +never linked. In operation the execution owner runs the helper of the DevGuard +release that serves the socket, named by `--devguard-launch-helper`. The +validation stages that run the launch tests build it from the DevGuard client +pin with DevGuard's own lockfile (`cargo install --locked --git ... --rev`, +`devguard_binaries()` in `scripts/validate-upstream.py`) and name it to the +tests in `CODESPACE_DEVGUARD_LAUNCH_BIN`. Product graphs are unchanged. diff --git a/scripts/tests/test_ci_plan.py b/scripts/tests/test_ci_plan.py index 950f92c..835ff2f 100644 --- a/scripts/tests/test_ci_plan.py +++ b/scripts/tests/test_ci_plan.py @@ -240,7 +240,8 @@ def compiled(stage): validation.cargo('test', 'root', '-p', 'codespace-runner', '--features', 'devguard', '--lib'), validation.cargo('test', 'root', '-p', 'codespace-domain', '--features', 'devguard', '--lib')] crates = set() - for command in (command for command in commands if command[0] == 'cargo'): + # `cargo install` builds DevGuard's launch helper from its own repository, not a crate here. + for command in (command for command in commands if command[0] == 'cargo' and command[1] != 'install'): manifest = [arg for arg in command if arg.startswith('crates/') and arg.endswith('/Cargo.toml')] package = command[command.index('-p') + 1] if '-p' in command else None if manifest: diff --git a/scripts/tests/test_upstream.py b/scripts/tests/test_upstream.py index b6289a9..78e9e13 100644 --- a/scripts/tests/test_upstream.py +++ b/scripts/tests/test_upstream.py @@ -154,10 +154,23 @@ def test_helper_paths_absolute(self): env = validation.devguard_env(Path('/tmp/build')) self.assertEqual(env['CODESPACE_DEVGUARD_RUNTIME_BIN'], '/tmp/build/debug/codespace-codex-runtime-devguard') self.assertEqual(env['CODESPACE_DEVGUARD_FIXTURE_BIN'], '/tmp/build/debug/examples/fixture_authority') + self.assertEqual(env['CODESPACE_DEVGUARD_LAUNCH_BIN'], '/tmp/build/devguard-launch/bin/devguard-launch') self.assertEqual(env['CODESPACE_REQUIRE_DEVGUARD_BINS'], '1') + def test_the_launch_helper_comes_from_the_reviewed_devguard_pin(self): + *_, helper = validation.devguard_binaries() + url, rev = validation.devguard_pin() + self.assertEqual(url, 'https://github.com/novelKR/DevGuard') + self.assertIn(rev, deps.DEVGUARD_SOURCE) + self.assertEqual(len(rev), 40) + self.assertEqual(helper[:4], ['cargo', 'install', '--locked', '--git']) + self.assertEqual(helper[helper.index('--git') + 1], url) + self.assertEqual(helper[helper.index('--rev') + 1], rev) + self.assertIn('devguard-launch', helper) + self.assertEqual(Path(helper[helper.index('--root') + 1]), validation.TARGET_DIR / 'devguard-launch') + def test_devguard_worker_is_built_before_and_kept_beside_the_default_one(self): - build, copy, fixture = validation.devguard_binaries() + build, copy, fixture, _ = validation.devguard_binaries() self.assertIn('--features', build) self.assertEqual(build[build.index('--features') + 1], 'devguard') self.assertEqual(copy[0], 'cp') diff --git a/scripts/validate-upstream.py b/scripts/validate-upstream.py index 6052a2c..b4da914 100644 --- a/scripts/validate-upstream.py +++ b/scripts/validate-upstream.py @@ -10,7 +10,7 @@ import sys import time -from upstream_dependencies import ADAPTERS, ROOT +from upstream_dependencies import ADAPTERS, DEVGUARD_SOURCE, ROOT def capture(*cmd): @@ -64,14 +64,15 @@ def stages(): # macOS path the Linux legs cannot exercise. + [cargo('test', 'root', '-p', 'codespace-runner', '--lib', '--', 'descriptors', 'fork_handlers', 'missing_executable_is_process_spawn_failed'), cargo('test', 'root', '-p', 'codespace-server', '--lib', '--', 'descriptors')] - # The DevGuard adapter against an authority with native host evidence, and the DevGuard - # tests of the runner (registration, its wire and the preparation of governed executions, - # CSRG-U3), the worker and the gateway where socket creation is not atomically - # close-on-exec, the gateway's against the worker and the fixture authority built here. + # The DevGuard adapter against an authority with native host evidence and DevGuard's + # launch helper, and the DevGuard tests of the runner (registration, its wire, the + # preparation of governed executions, CSRG-U3, and their launch, CSRG-U4), the worker + # and the gateway where socket creation is not atomically close-on-exec, the gateway's + # against the worker, the fixture authority and the helper built here. + devguard_binaries() + [cargo('test', 'devguard'), cargo('test', 'codex-runtime', '--features', 'devguard', '--bin', 'codespace-codex-runtime'), - cargo('test', 'root', '-p', 'codespace-runner', '--features', 'devguard', '--lib', '--', 'registration', 'wire', 'admission'), + cargo('test', 'root', '-p', 'codespace-runner', '--features', 'devguard', '--lib', '--', 'registration', 'wire', 'admission', 'launch'), cargo('test', 'root', '-p', 'codespace-server', '--features', 'devguard', '--lib', '--', 'devguard')], # Children killed inside fork on macOS: fresh-process trials of reads concurrent with # pipe spawns (crates/runner/tests/fork_race.rs); any child without an exit code fails. @@ -99,9 +100,11 @@ def stages(): 'linux-sandbox': ('codespace-linux-sandbox', 'CODESPACE_LINUX_SANDBOX_BIN')} TARGET_DIR = (ROOT / 'target' / 'upstream-validation').resolve() # The gateway's DevGuard registration tests (CSRG-U2) need the worker built with its DevGuard -# feature, kept beside the default worker, and DevGuard's fixture authority in its own process. +# feature, kept beside the default worker, and DevGuard's fixture authority in its own process; +# the launch tests (CSRG-U4) need DevGuard's launch helper. DEVGUARD_RUNTIME = 'codespace-codex-runtime-devguard' DEVGUARD_FIXTURE = 'examples/fixture_authority' +DEVGUARD_LAUNCH = 'devguard-launch' DEVGUARD_STAGES = ('integration', 'macos-core') @@ -114,16 +117,28 @@ def devguard_env(target_dir): test that needs one fails instead of skipping.""" return {'CODESPACE_DEVGUARD_RUNTIME_BIN': str(target_dir / 'debug' / DEVGUARD_RUNTIME), 'CODESPACE_DEVGUARD_FIXTURE_BIN': str(target_dir / 'debug' / DEVGUARD_FIXTURE), + 'CODESPACE_DEVGUARD_LAUNCH_BIN': str(target_dir / DEVGUARD_LAUNCH / 'bin' / DEVGUARD_LAUNCH), 'CODESPACE_REQUIRE_DEVGUARD_BINS': '1'} +def devguard_pin(): + """The reviewed DevGuard repository and commit that every `devguard-*` crate comes from.""" + url, rest = DEVGUARD_SOURCE.removeprefix('git+').split('?rev=', 1) + return url, rest.split('#', 1)[0] + + def devguard_binaries(): """Build the worker with its DevGuard feature and copy it beside the default worker, whose - build replaces that path, then the fixture authority.""" + build replaces that path, then the fixture authority, then DevGuard's launch helper from the + reviewed pin with DevGuard's own lockfile. The helper is a test binary here: no CodeSpace + product links or ships it.""" worker = TARGET_DIR / 'debug' / 'codespace-codex-runtime' + url, rev = devguard_pin() return [cargo('build', 'codex-runtime', '--features', 'devguard', '--bin', 'codespace-codex-runtime'), ['cp', str(worker), str(TARGET_DIR / 'debug' / DEVGUARD_RUNTIME)], - cargo('build', 'devguard', '--example', 'fixture_authority')] + cargo('build', 'devguard', '--example', 'fixture_authority'), + ['cargo', 'install', '--locked', '--git', url, '--rev', rev, DEVGUARD_LAUNCH, + '--root', str(TARGET_DIR / DEVGUARD_LAUNCH), '--target-dir', str(TARGET_DIR / (DEVGUARD_LAUNCH + '-build'))]] def execute(commands, env, log): From d1dc65878e8359e81aa08f540b37e58f3eb32c9a Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 18:06:20 +0900 Subject: [PATCH 3/7] runner, domain: test carrier cleanup and timeouts of governed processes (CSRG-U4) - A launch test in a process of its own checks that the owner holds neither of the helper's descriptors once the helper has started, on a pipe and on a PTY. - A governed process times out as any other, through CodeSpace's own timeout, on a pipe and on a PTY. - The registration participation's description no longer says that nothing is admitted or launched through the authority. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/domain/src/resource_authority.rs | 3 +- crates/runner/src/launch.rs | 89 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/domain/src/resource_authority.rs b/crates/domain/src/resource_authority.rs index 6134dc2..cfc5955 100644 --- a/crates/domain/src/resource_authority.rs +++ b/crates/domain/src/resource_authority.rs @@ -40,7 +40,8 @@ pub enum ResourceParticipation { /// launch. Status, /// The process that owns CodeSpace's executions registers itself with the authority and - /// reports its status. Nothing is admitted or launched through the authority. + /// reports its status. It admits the executions of workspaces that require resource + /// participation, and with a launch helper launches them, through the authority. Registration, } diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index 97adda9..ecc5e0d 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -1429,6 +1429,95 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} ); } + /// A governed process times out as any other: CodeSpace's own timeout kills it, and its + /// status says so. + #[tokio::test] + async fn a_governed_process_times_out_like_any_other() { + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let mut req = request(&["/bin/sleep", "30"], &format!("proc-timeout-{tty}")); + req.tty = tty; + req.timeout_ms = 300; + let launch = + launched(launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await); + assert_eq!(launch.dispatch, LaunchDispatch::Confirmed); + let mut status = runner.process_status(&launch.process_id).await.unwrap(); + for _ in 0..250 { + if status.state == ProcessState::Exited { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + status = runner.process_status(&launch.process_id).await.unwrap(); + } + assert_eq!( + (status.state, status.termination), + ( + ProcessState::Exited, + Some(codespace_domain::ProcessTermination::Timeout) + ), + "tty={tty}" + ); + assert_eq!(fake.count(ABANDON), 0); + } + } + + /// Once the helper has started, the owner holds neither of its descriptors: they close when + /// the spawn returns, and the transcript's reader when the transcript ends. It runs in a + /// process of its own, where no other test reuses the numbers. + #[test] + fn the_owner_keeps_no_carrier_once_the_helper_started() { + use crate::fork_handlers::tests::{in_isolated_copy, run_isolated}; + if !in_isolated_copy() { + run_isolated("launch::tests::the_owner_keeps_no_carrier_once_the_helper_started"); + return; + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let helpers = helper_directory(); + let entry = r#"echo "carriers: $permit $report""#; + let helper = stand_in(helpers.path(), "carriers", entry, READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let mut req = request(&["/bin/echo", "done"], &format!("proc-carriers-{tty}")); + req.tty = tty; + let launch = launched( + launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await, + ); + let output = finished_output(&runner, &launch.process_id).await; + let carriers: Vec = output + .lines() + .find_map(|line| line.strip_prefix("carriers: ")) + .unwrap_or_else(|| panic!("{output:?}")) + .split(' ') + .map(|fd| fd.parse().unwrap()) + .collect(); + assert_eq!(carriers.len(), 2, "{output:?}"); + for fd in carriers { + // SAFETY: F_GETFD only reads a descriptor's flags. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + let error = std::io::Error::last_os_error().raw_os_error(); + assert_eq!( + (flags, error), + (-1, Some(libc::EBADF)), + "tty={tty}: the owner still holds {fd}" + ); + } + } + }); + } + #[test] fn programs_resolve_as_the_spawn_would() { let dir = tempfile::tempdir().unwrap(); From 8894955058b4d717f6f22a8e5762564d8a7f01c1 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 18:17:25 +0900 Subject: [PATCH 4/7] devguard: keep the launch helper lookup with the macOS tests that use it (CSRG-U4) `helper_bin` is used only by the adapter's native launch tests, which run on macOS. On Linux the test build left it unused, and CI's `clippy-adapters` failed with dead code under `-D warnings`. It now lives in the `native` module, beside its only callers. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/src/launch.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/devguard/src/launch.rs b/crates/devguard/src/launch.rs index f2aa4d8..3f037d8 100644 --- a/crates/devguard/src/launch.rs +++ b/crates/devguard/src/launch.rs @@ -445,22 +445,6 @@ pub(crate) mod tests { use crate::{OwnerCredential, OwnerSettings}; use std::os::unix::fs::PermissionsExt; - /// DevGuard's launch helper built from the pinned source, named by - /// `CODESPACE_DEVGUARD_LAUNCH_BIN`. Without it a test that needs it is skipped, unless - /// `CODESPACE_REQUIRE_DEVGUARD_BINS` says the stage built it, which then fails the test. - pub(crate) fn helper_bin() -> Option { - match std::env::var_os("CODESPACE_DEVGUARD_LAUNCH_BIN") { - Some(path) => Some(PathBuf::from(path)), - None if std::env::var_os("CODESPACE_REQUIRE_DEVGUARD_BINS").is_some() => { - panic!("CODESPACE_DEVGUARD_LAUNCH_BIN is required by this stage") - } - None => { - eprintln!("skipped: CODESPACE_DEVGUARD_LAUNCH_BIN is not set"); - None - } - } - } - #[test] fn a_permit_never_shows_itself() { let text = "ab".repeat(32); @@ -829,6 +813,22 @@ pub(crate) mod tests { use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; + /// DevGuard's launch helper built from the pinned source, named by + /// `CODESPACE_DEVGUARD_LAUNCH_BIN`. Without it a test that needs it is skipped, unless + /// `CODESPACE_REQUIRE_DEVGUARD_BINS` says the stage built it, which then fails the test. + fn helper_bin() -> Option { + match std::env::var_os("CODESPACE_DEVGUARD_LAUNCH_BIN") { + Some(path) => Some(PathBuf::from(path)), + None if std::env::var_os("CODESPACE_REQUIRE_DEVGUARD_BINS").is_some() => { + panic!("CODESPACE_DEVGUARD_LAUNCH_BIN is required by this stage") + } + None => { + eprintln!("skipped: CODESPACE_DEVGUARD_LAUNCH_BIN is not set"); + None + } + } + } + fn admitting() -> Provisioned { let provisioned = provision(2); provisioned From 8bc8af0ff3485ba36c3982b4290d0cfd83113faa Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 18:31:25 +0900 Subject: [PATCH 5/7] runner, docs: run the launch stand-in under bash; resolve on the platform's default path (CSRG-U4) CI's Linux integration job failed ten runner launch tests. In nine, the stand-in helper ended before READY: it was a `/bin/sh` script that redirects the carriers by number, and dash, Linux's `sh`, takes only one-digit descriptor numbers in redirections, while the carriers sit above 9 in a busy test process. The stand-in now runs under bash. The real helper is DevGuard's Rust program. In the tenth, `resolve_program` searched macOS's default path (`/usr/bin:/bin`) on every platform when the environment has no `PATH`, while glibc's `execvp` searches `/bin:/usr/bin`, so on Linux it could name a different file than the spawn would. It now uses the platform's default. Managed launch is not supported on Linux yet, so no execution reached this. The documentation's helper rule now states the group write by gid 0 that `check_helper` accepts. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/runner/src/launch.rs | 19 +++++++++++++++---- docs/devguard-integration.md | 2 +- docs/ko/devguard-integration.md | 2 +- docs/translations.json | 4 ++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index ecc5e0d..8892981 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -148,9 +148,16 @@ struct Plan { args: Vec, } +/// The search path of `execvp` when the environment has none: the C library's default. +#[cfg(target_os = "macos")] +const DEFAULT_PATH: &str = "/usr/bin:/bin"; +/// The search path of `execvp` when the environment has none: glibc's default. +#[cfg(not(target_os = "macos"))] +const DEFAULT_PATH: &str = "/bin:/usr/bin"; + /// `name` resolved as the spawn would resolve it: a name with a slash is a path, relative to the -/// working directory; a bare name is searched on `path` (`/usr/bin:/bin` when unset, as -/// `execvp` does), an empty entry meaning the working directory. The first regular file this +/// working directory; a bare name is searched on `path` (the C library's default when unset, +/// as `execvp` does), an empty entry meaning the working directory. The first regular file this /// user may execute wins. The helper runs the executable by this absolute path, which is /// therefore its `argv[0]`. pub fn resolve_program(name: &str, cwd: &Path, path: Option<&str>) -> Option { @@ -168,7 +175,7 @@ pub fn resolve_program(name: &str, cwd: &Path, path: Option<&str>) -> Option PathBuf { let script = format!( - r#"#!/bin/sh + r#"#!/bin/bash while [ "$1" != "--" ]; do case "$1" in --permit-fd) permit="$2" ;; @@ -1547,6 +1556,8 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} resolve_program("/bin/sh", cwd, Some("")), Some(PathBuf::from("/bin/sh")) ); + // Without `PATH`, the C library's default: macOS has no `/usr/bin/sh`, and glibc + // searches `/bin` first. assert_eq!( resolve_program("sh", cwd, None), Some(PathBuf::from("/bin/sh")) diff --git a/docs/devguard-integration.md b/docs/devguard-integration.md index 4b657f5..e6b0cea 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -177,7 +177,7 @@ CSRG-U4 launches a prepared execution once under DevGuard: with `required`, the **Order.** After the [preparation](#devguard-admission) of CSRG-U3, the execution owner: -1. **checks what it can before committing anything**: the prepared command and its deadline; the helper, which must be a regular executable file that no other user can replace (owned by this user or root, not writable by group or others, under directories with the same property); and the program, resolved to an absolute path on the execution's own `PATH` as the spawn would resolve it. A failure here cancels the unstarted attempt. +1. **checks what it can before committing anything**: the prepared command and its deadline; the helper, which must be a regular executable file that no other user can replace (owned by this user or root and writable by no one else: not by others, and by its group only when that is root's group, gid 0, `wheel` on macOS; under directories with the same property, apart from a root-owned sticky `/tmp`); and the program, resolved to an absolute path on the execution's own `PATH` as the spawn would resolve it. A failure here cancels the unstarted attempt. 2. **sends `BeginLaunch` once**, in a bounded session of the registered owner (`Register`, then `BeginLaunch`). Only its first answer carries the attempt's one-time permit, and only a permit that comes with this owner's committed record of this attempt and meaning is accepted. 3. **starts the helper through its own spawner**, as its own direct child, filling the slot the preparation took. DevGuard's `helper_command` builds the invocation: the helper's arguments, which carry no secret, and two private descriptors, the permit carrier (a socket pair holding the permit, its writer closed) and the writer of the helper's transcript (a pipe). Only the helper receives them. 4. **reads the transcript** to its end. The helper leads its own process group and presents the grant; DevGuard binds that group as the attempt's scope, applies and reads back the policy, and authorizes the run. The helper then reports READY and executes the program, which keeps the helper's PID. READY followed by the transcript's end is a started command. diff --git a/docs/ko/devguard-integration.md b/docs/ko/devguard-integration.md index 6e6479b..8567384 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -177,7 +177,7 @@ CSRG-U4๋Š” ์ค€๋น„๋œ ์‹คํ–‰์„ DevGuard ์•„๋ž˜์—์„œ ํ•œ ๋ฒˆ ์‹คํ–‰ํ•œ๋‹ค. `requ **์ˆœ์„œ.** CSRG-U3์˜ [์ค€๋น„](#devguard-admission) ๋‹ค์Œ์— ์‹คํ–‰ ์†Œ์œ ์ž๋Š” ๋‹ค์Œ์„ ํ•œ๋‹ค. -1. **๋ฌด์—‡์„ ํ™•์ •ํ•˜๊ธฐ ์ „์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•œ๋‹ค.** ์ค€๋น„๋œ ๋ช…๋ น๊ณผ ๊ทธ ๊ธฐํ•œ, helper(๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ”๊ฟ” ๋†“์„ ์ˆ˜ ์—†๋Š” ์ผ๋ฐ˜ ์‹คํ–‰ ํŒŒ์ผ์ด์–ด์•ผ ํ•œ๋‹ค. ์ด ์‚ฌ์šฉ์ž๋‚˜ root๊ฐ€ ์†Œ์œ ํ•˜๊ณ  group๊ณผ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ์“ธ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ฒฝ๋กœ์˜ ๋””๋ ‰ํ„ฐ๋ฆฌ๋„ ๊ทธ๋ž˜์•ผ ํ•œ๋‹ค), ๊ทธ๋ฆฌ๊ณ  ์‹คํ–‰ ๊ฒฝ๋กœ๊ฐ€ ์ฐพ์„ ๋ฐฉ์‹๋Œ€๋กœ ์‹คํ–‰์˜ `PATH`์—์„œ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ์ฐพ์€ ํ”„๋กœ๊ทธ๋žจ์ด๋‹ค. ์—ฌ๊ธฐ์„œ ์‹คํŒจํ•˜๋ฉด ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•œ๋‹ค. +1. **๋ฌด์—‡์„ ํ™•์ •ํ•˜๊ธฐ ์ „์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•œ๋‹ค.** ์ค€๋น„๋œ ๋ช…๋ น๊ณผ ๊ทธ ๊ธฐํ•œ, helper(๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ”๊ฟ” ๋†“์„ ์ˆ˜ ์—†๋Š” ์ผ๋ฐ˜ ์‹คํ–‰ ํŒŒ์ผ์ด์–ด์•ผ ํ•œ๋‹ค. ์ด ์‚ฌ์šฉ์ž๋‚˜ root๊ฐ€ ์†Œ์œ ํ•˜๊ณ  ๊ทธ ๋ฐ–์˜ ๋ˆ„๊ตฌ๋„ ์“ธ ์ˆ˜ ์—†์–ด์•ผ ํ•œ๋‹ค. ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋Š” ์“ธ ์ˆ˜ ์—†๊ณ , group์€ ๊ทธ group์ด root์˜ group(gid 0, macOS์˜ `wheel`)์ผ ๋•Œ๋งŒ ์“ธ ์ˆ˜ ์žˆ๋‹ค. ๊ฒฝ๋กœ์˜ ๋””๋ ‰ํ„ฐ๋ฆฌ๋„ ๊ทธ๋ž˜์•ผ ํ•˜๋ฉฐ, root๊ฐ€ ์†Œ์œ ํ•œ sticky `/tmp`๋Š” ์˜ˆ์™ธ๋‹ค), ๊ทธ๋ฆฌ๊ณ  ์‹คํ–‰ ๊ฒฝ๋กœ๊ฐ€ ์ฐพ์„ ๋ฐฉ์‹๋Œ€๋กœ ์‹คํ–‰์˜ `PATH`์—์„œ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ์ฐพ์€ ํ”„๋กœ๊ทธ๋žจ์ด๋‹ค. ์—ฌ๊ธฐ์„œ ์‹คํŒจํ•˜๋ฉด ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ attempt๋ฅผ ์ทจ์†Œํ•œ๋‹ค. 2. **`BeginLaunch`๋ฅผ ํ•œ ๋ฒˆ ๋ณด๋‚ธ๋‹ค.** ๋“ฑ๋ก๋œ ์†Œ์œ ์ž์˜ ์ƒํ•œ ์žˆ๋Š” ์„ธ์…˜(`Register` ํ›„ `BeginLaunch`)์—์„œ ๋ณด๋‚ธ๋‹ค. ์ฒซ ๋‹ต๋งŒ attempt์˜ ์ผํšŒ์šฉ permit์„ ๋‹ด์œผ๋ฉฐ, ์ด ์†Œ์œ ์ž๊ฐ€ ํ™•์ •ํ•œ ์ด attemptยท์˜๋ฏธ์˜ ๊ธฐ๋ก๊ณผ ํ•จ๊ป˜ ์˜จ permit๋งŒ ๋ฐ›์•„๋“ค์ธ๋‹ค. 3. **์ž์‹ ์˜ ์‹คํ–‰ ๊ฒฝ๋กœ๋กœ helper๋ฅผ ์‹œ์ž‘ํ•œ๋‹ค.** ์ž์‹ ์˜ ์ง๊ณ„ ์ž์‹์œผ๋กœ ์‹œ์ž‘ํ•˜๋ฉฐ ์ค€๋น„๊ฐ€ ์žก์•„ ๋‘” slot์„ ์ฑ„์šด๋‹ค. ํ˜ธ์ถœ์€ DevGuard์˜ `helper_command`๊ฐ€ ๋งŒ๋“ ๋‹ค. secret์ด ์—†๋Š” helper ์ธ์ž์™€ ๋น„๊ณต๊ฐœ descriptor ๋‘ ๊ฐœ, ์ฆ‰ permit carrier(permit์„ ๋‹ด๊ณ  ์“ฐ๋Š” ์ชฝ์„ ๋‹ซ์€ socket pair)์™€ helper transcript์˜ ์“ฐ๋Š” ์ชฝ(pipe)์ด๋‹ค. ์ด ๋‘˜์€ helper๋งŒ ๋ฐ›๋Š”๋‹ค. 4. **transcript๋ฅผ ๋๊นŒ์ง€ ์ฝ๋Š”๋‹ค.** helper๋Š” ์ž์‹ ์˜ process group์„ ์ด๋Œ๊ณ  grant๋ฅผ ์ œ์‹œํ•œ๋‹ค. DevGuard๋Š” ๊ทธ group์„ attempt์˜ scope๋กœ ๋ฌถ๊ณ  ์ •์ฑ…์„ ์ ์šฉํ•ด ๋‹ค์‹œ ์ฝ์€ ๋’ค ์‹คํ–‰์„ ์Šน์ธํ•œ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด helper๊ฐ€ READY๋ฅผ ๋ณด๊ณ ํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉฐ, ํ”„๋กœ๊ทธ๋žจ์€ helper์˜ PID๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค. READY ๋’ค transcript๊ฐ€ ๋๋‚˜๋ฉด ๋ช…๋ น์ด ์‹œ์ž‘๋œ ๊ฒƒ์ด๋‹ค. diff --git a/docs/translations.json b/docs/translations.json index 555ed85..ba2f738 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -72,8 +72,8 @@ "์ตœ์†Œ-๋„์ž…-์กฐ๊ฑด", "์ตœ์ดˆ-gateway-๋ณต๊ตฌ-๋ฒ”์œ„" ], - "source_sha256": "dbd0ae11c2ded4c9304c6ece9b69669219ce0b9959ba41216d7dbf3372b0efdd", - "translation_sha256": "c9342e6fb72b077da2ebab4fe42d647ecb9d962288eeda2e0904109a9f3adc2e" + "source_sha256": "2fbe1076b56e3b176b013e87265f3ab4b61b6d43d040ed36a0503e0c4e7e248e", + "translation_sha256": "7027d63339d3fcb07c35a6be5efaeef4aa6c9eaf47b517191a294d1cd82baacd" }, { "id": "overview", From d5ef68a482ae3b1768825a524e8235ecab4069a3 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 18:57:00 +0900 Subject: [PATCH 6/7] server: let DevGuard release each D6 round before the next (CSRG-U4) CI's macOS job failed the D6 test: DevGuard refused a governed launch of a later round with resource_unavailable. Each round was admitted while earlier rounds' attempts were still charged: DevGuard releases a launched attempt only once its reconciler has seen the scope end, and the test waited for that only after its last round. On the 3-CPU runner DevGuard's CPU work capacity is 480 m (3000 less 2000 of host headroom, 500 for the system and 20 reserved), nine 50 m attempts, which the rounds outgrew. With the request raised so that this Mac also holds nine, the unchanged test was refused the same way, with 0, 2, 4, 6 and 8 attempts charged at the round starts; waiting at the end of each round, every round started with none charged. The test now waits for, and asserts, each round's release. The product is unchanged: DevGuard refused, and CodeSpace reported a refusal that started nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/server/src/mcp.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index e7d6afb..69e559e 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -3529,13 +3529,17 @@ mod devguard_tests { control_held += 1; } } + // Each round is admitted against what DevGuard still holds, and it releases a + // launched attempt only once its reconciler has seen the scope end: without this + // wait, rounds pile up attempts that a small host (three CPUs give 480 m of work, + // nine of these) cannot hold. + assert_eq!(released(&mut fixture), (Vec::new(), 0), "round {round}"); } assert_eq!( control_held, rounds * 4, "the control must see the inheritable descriptor" ); - assert_eq!(released(&mut fixture), (Vec::new(), 0)); eprintln!( "u4 d6: {governed} governed launches and {ordinary} ordinary children held no \ descriptor beyond stdio; the unguarded control held the inheritable descriptor \ From c1c785962fc830cb41519c202c24814c56edd6c4 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Fri, 9 Oct 2026 19:11:10 +0900 Subject: [PATCH 7/7] runner, docs: give the governed timeout test room for the helper's READY (CSRG-U4) macos-core failed once here on a_governed_process_times_out_like_any_other: the launch was reported as not started (HelperEnded). The test set the request's timeout to 300 ms, and CodeSpace's timeout runs from the spawn, which is the helper's. Under the load of the parallel tests the stand-in helper had not reported READY by then, so the timeout ended it first and the launch was rightly reported as not started. With a 5 ms timeout the test failed this way in three runs of three. The test means a timeout of the running command, so it now allows 2 s (the command sleeps 30 s); the macos-core runner command then passed in five runs of five. The documentation now says that a governed execution's timeout runs from the helper's spawn, and so also covers the helper's exchange with DevGuard before READY. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/runner/src/launch.rs | 6 ++++-- docs/devguard-integration.md | 3 ++- docs/ko/devguard-integration.md | 3 ++- docs/translations.json | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index 8892981..d20b843 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -1451,12 +1451,14 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} let fake = Fake::admitting([prepared()]); let mut req = request(&["/bin/sleep", "30"], &format!("proc-timeout-{tty}")); req.tty = tty; - req.timeout_ms = 300; + // The timeout runs from the helper's spawn, so it must leave the helper time to + // report READY under load; one that ends first ends the launch before the command. + req.timeout_ms = 2_000; let launch = launched(launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await); assert_eq!(launch.dispatch, LaunchDispatch::Confirmed); let mut status = runner.process_status(&launch.process_id).await.unwrap(); - for _ in 0..250 { + for _ in 0..500 { if status.state == ProcessState::Exited { break; } diff --git a/docs/devguard-integration.md b/docs/devguard-integration.md index e6b0cea..2d8cb10 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -184,11 +184,12 @@ CSRG-U4 launches a prepared execution once under DevGuard: with `required`, the DevGuard requires the helper's parent to be the registered owner, so the owner launches: the gateway in `in-process` mode, and in `uds` mode the worker the gateway started, which receives one `launch` request. The worker states `launch` in its `Hello` only when it registers and has a helper; the request is never sent again. -**The process.** From the spawn on it is a CodeSpace process like any other: the same slot, output, stdin, `process_resize`, timeout, `terminate_process`, workspace lease and reaping, under the prepared `process_id`. It differs from an ungoverned execution in three ways: +**The process.** From the spawn on it is a CodeSpace process like any other: the same slot, output, stdin, `process_resize`, timeout, `terminate_process`, workspace lease and reaping, under the prepared `process_id`. It differs from an ungoverned execution in four ways: - the command runs with its resolved absolute path as `argv[0]`; - on a pipe it leads its own process group, as the helper makes it; on a PTY it leads its own session, as before; - on a PTY, a death by a signal is reported as exit code 128 plus the signal number instead of 1, as the pinned Codex PTY's descriptor-keeping path reports it. +- its timeout runs from the helper's spawn, so it also covers the helper's exchange with DevGuard before READY: a timeout that runs out first ends the launch before the command starts, which is reported as not started. **Results.** `exec_command` returns once the transcript has ended, or after 10 s: diff --git a/docs/ko/devguard-integration.md b/docs/ko/devguard-integration.md index 8567384..3eaec63 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -184,11 +184,12 @@ CSRG-U4๋Š” ์ค€๋น„๋œ ์‹คํ–‰์„ DevGuard ์•„๋ž˜์—์„œ ํ•œ ๋ฒˆ ์‹คํ–‰ํ•œ๋‹ค. `requ DevGuard๋Š” helper์˜ ๋ถ€๋ชจ๊ฐ€ ๋“ฑ๋ก๋œ ์†Œ์œ ์ž์ด๊ธฐ๋ฅผ ์š”๊ตฌํ•˜๋ฏ€๋กœ ์‹คํ–‰์€ ์†Œ์œ ์ž๊ฐ€ ํ•œ๋‹ค. `in-process` ๋ชจ๋“œ์—์„œ๋Š” gateway๊ฐ€, `uds` ๋ชจ๋“œ์—์„œ๋Š” gateway๊ฐ€ ์‹œ์ž‘ํ•œ worker๊ฐ€ `launch` ์š”์ฒญ ํ•˜๋‚˜๋ฅผ ๋ฐ›์•„ ์‹คํ–‰ํ•œ๋‹ค. worker๋Š” ๋“ฑ๋กํ•˜๊ณ  helper๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ `Hello`์— `launch`๋ฅผ ๋ฐํžˆ๋ฉฐ, ์ด ์š”์ฒญ์€ ๋‹ค์‹œ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค. -**ํ”„๋กœ์„ธ์Šค.** spawn ๋’ค์—๋Š” ๋‹ค๋ฅธ CodeSpace ํ”„๋กœ์„ธ์Šค์™€ ๊ฐ™๋‹ค. ๊ฐ™์€ slot, ์ถœ๋ ฅ, stdin, `process_resize`, timeout, `terminate_process`, workspace lease, reap์„ ์ค€๋น„๋œ `process_id`๋กœ ์“ด๋‹ค. ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š” ์‹คํ–‰๊ณผ๋Š” ์„ธ ๊ฐ€์ง€๊ฐ€ ๋‹ค๋ฅด๋‹ค. +**ํ”„๋กœ์„ธ์Šค.** spawn ๋’ค์—๋Š” ๋‹ค๋ฅธ CodeSpace ํ”„๋กœ์„ธ์Šค์™€ ๊ฐ™๋‹ค. ๊ฐ™์€ slot, ์ถœ๋ ฅ, stdin, `process_resize`, timeout, `terminate_process`, workspace lease, reap์„ ์ค€๋น„๋œ `process_id`๋กœ ์“ด๋‹ค. ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š” ์‹คํ–‰๊ณผ๋Š” ๋„ค ๊ฐ€์ง€๊ฐ€ ๋‹ค๋ฅด๋‹ค. - ๋ช…๋ น์€ ์ฐพ์•„๋‚ธ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ `argv[0]`๋กœ ๋ฐ›์•„ ์‹คํ–‰๋œ๋‹ค. - pipe์—์„œ๋Š” helper ๋•Œ๋ฌธ์— ์ž์‹ ์˜ process group์„ ์ด๋ˆ๋‹ค. PTY์—์„œ๋Š” ์ด์ „์ฒ˜๋Ÿผ ์ž์‹ ์˜ session์„ ์ด๋ˆ๋‹ค. - PTY์—์„œ signal๋กœ ์ฃฝ์œผ๋ฉด ๊ณ ์ •๋œ Codex PTY์˜ descriptor ์œ ์ง€ ๊ฒฝ๋กœ๊ฐ€ ๋ณด๊ณ ํ•˜๋Š” ๋Œ€๋กœ 1์ด ์•„๋‹ˆ๋ผ 128์— signal ๋ฒˆํ˜ธ๋ฅผ ๋”ํ•œ exit code๋กœ ๋ณด๊ณ ํ•œ๋‹ค. +- timeout์€ helper๋ฅผ spawnํ•œ ๋•Œ๋ถ€ํ„ฐ ์„ธ๋ฏ€๋กœ READY ์ „์— helper๊ฐ€ DevGuard์™€ ์ฃผ๊ณ ๋ฐ›๋Š” ์‹œ๊ฐ„๋„ ํฌํ•จํ•œ๋‹ค. ๊ทธ ์ „์— timeout์ด ๋๋‚˜๋ฉด ๋ช…๋ น์„ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์— ์‹คํ–‰์ด ๋๋‚˜๋ฉฐ, ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ ํ•œ๋‹ค. **๊ฒฐ๊ณผ.** `exec_command`๋Š” transcript๊ฐ€ ๋๋‚˜๊ฑฐ๋‚˜ 10์ดˆ๊ฐ€ ์ง€๋‚˜๋ฉด ๋Œ์•„์˜จ๋‹ค. diff --git a/docs/translations.json b/docs/translations.json index ba2f738..2dea601 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -72,8 +72,8 @@ "์ตœ์†Œ-๋„์ž…-์กฐ๊ฑด", "์ตœ์ดˆ-gateway-๋ณต๊ตฌ-๋ฒ”์œ„" ], - "source_sha256": "2fbe1076b56e3b176b013e87265f3ab4b61b6d43d040ed36a0503e0c4e7e248e", - "translation_sha256": "7027d63339d3fcb07c35a6be5efaeef4aa6c9eaf47b517191a294d1cd82baacd" + "source_sha256": "945abaa05fb2da9ff17045670fabc83d0e4a20719281c9b5815dc40354198e73", + "translation_sha256": "c7f9aba3d8ffb3876a7b5a196dd327c6f6d8a114df49d99e9c449b56d6640b41" }, { "id": "overview",