qcode/ui/workspace/plan.rs
1//! What a tab runs, and where.
2//!
3//! A tab never starts a program on the machine QCode runs on. It starts the engine binary, and
4//! the engine carries the program into a container. Everything in this module is therefore a
5//! plan rather than an action: a [`ContainerPlan`] knows a container's name, image, mounts and
6//! network, and turns them into the exact [`EngineCommand`] that creates it or runs something
7//! inside it. The commands can be read back in tests on a machine with no engine at all, which
8//! is how the promise "nothing runs on the host" is checked rather than assumed.
9//!
10//! [`ensure_running`] is the one place that actually runs an engine; it belongs to a background
11//! thread, never to `update` or `view`.
12
13use std::path::{Path, PathBuf};
14
15use crate::base;
16use crate::bridge::config::{self, Unregistered};
17use crate::desktop::{self, Display};
18use crate::engine::{
19 Access, Container, ContainerCreate, ContainerState, Engine, EngineCommand, Exec, HostUser, Mount, MountSource,
20 Network, RunOnce,
21 known::{self, Known},
22 names,
23 run::{self, EngineError},
24};
25use crate::profile::guidance::{self, Unguided};
26use crate::profile::identity::{self, Home};
27use crate::profile::{Desktop, Extra, HarnessKind, MountAccess, NetworkMode, Profile};
28use crate::store::{WorkspaceId, WorkspacePaths};
29
30use super::keep;
31
32/// The places every container agrees on, taken from the one contract the base image is built to.
33///
34/// They are re-exported here because the plan is what callers and tests reach for when they ask
35/// where a mount lands; the definition stays with the image so the two can never disagree.
36pub use crate::base::paths::{ASSETS_DIR, CODE_DIR, HOME_DIR, KEEP_ALIVE, MCP_DIR};
37
38/// The label a container carries the digest of the plan it was made from in.
39pub const PLAN_LABEL: &str = "qcode.plan";
40
41/// What an empty terminal tab runs: a login shell in the workspace's base container.
42pub const SHELL: &[&str] = &["sh", "-l"];
43
44/// A container QCode keeps for a workspace: everything needed to create it and to enter it.
45///
46/// One plan per workspace for the plain shell ([`ContainerPlan::base`]) and one per profile the
47/// workspace carries ([`ContainerPlan::profile`]).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ContainerPlan {
50 /// The container's name, which is how QCode finds it again after a restart.
51 pub name: String,
52 /// The image it is created from.
53 pub image: String,
54 /// The workspace's own copy of the profile's home, mounted at [`HOME_DIR`], for a profile
55 /// container; the base container has none because no harness lives in it.
56 pub home: Option<Home>,
57 /// The workspace's own files on the host, mounted writable at [`CODE_DIR`].
58 pub code: PathBuf,
59 /// The workspace's other material on the host, mounted at [`ASSETS_DIR`].
60 pub assets: PathBuf,
61 /// Whether the container may write to [`ASSETS_DIR`].
62 pub assets_access: Access,
63 /// Whether the container reaches the network.
64 pub network: Network,
65 /// The window this container opens, for the container of a desktop profile; `None` for every
66 /// container a tab enters with a terminal.
67 pub window: Option<&'static Desktop>,
68 /// The bridge between the workspace's tabs, for a profile container: the workspace's
69 /// `Containers/MCP/` on the host, mounted read-only at [`MCP_DIR`], and the harness whose
70 /// settings the bridge's server is registered in. The base container has none, because no
71 /// harness runs in it.
72 pub bridge: Option<Bridge>,
73 /// Where a window's container leaves the web addresses it wants opened: the workspace's
74 /// `Containers/Browser/` on the host, mounted writable at [`desktop::signin::OPEN_DIR`].
75 /// `None` for every container that opens no window.
76 pub browser: Option<PathBuf>,
77 /// The harness whose instruction files in the workspace are brought up to date when the
78 /// container comes up — graphify's section and hooks, and QCode's own section — for a profile
79 /// on QCode high; `None` for every other container, which leaves the workspace's files alone.
80 pub guidance: Option<HarnessKind>,
81 /// Whether graphify is in the profile's image, so that its map is built in the workspace when
82 /// the container comes up, and, for a profile on QCode high, its installer runs there. False
83 /// for the base container and for a profile on base or one that went without graphify.
84 pub graphify: bool,
85}
86
87/// The bridge a profile container carries.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Bridge {
90 /// The workspace's `Containers/MCP/` on the host.
91 pub folder: PathBuf,
92 /// The harness of the profile.
93 pub harness: HarnessKind,
94}
95
96impl ContainerPlan {
97 /// The workspace's plain shell container: the base image, the workspace's own folders, and no
98 /// harness home because no harness runs in it.
99 #[must_use]
100 pub fn base(workspace: &str, paths: &WorkspacePaths) -> Self {
101 Self {
102 name: names::base_container(workspace),
103 image: names::BASE_IMAGE.to_owned(),
104 home: None,
105 code: paths.code.clone(),
106 assets: paths.assets.clone(),
107 assets_access: Access::ReadWrite,
108 network: Network::Full,
109 window: None,
110 bridge: None,
111 browser: None,
112 guidance: None,
113 graphify: false,
114 }
115 }
116
117 /// The container one profile of the workspace lives in: the profile's image, the profile's
118 /// permissions and the workspace's own copy of the profile's home.
119 #[must_use]
120 pub fn profile(workspace: &WorkspaceId, paths: &WorkspacePaths, profile: &Profile) -> Self {
121 Self {
122 name: names::profile_container(workspace.as_str(), profile.name.as_str()),
123 image: profile.image(),
124 home: Some(Home::new(profile.name.clone(), workspace.clone())),
125 code: paths.code.clone(),
126 assets: paths.assets.clone(),
127 assets_access: access(profile.assets),
128 network: network(profile.network),
129 window: None,
130 bridge: Some(Bridge { folder: paths.mcp(), harness: profile.harness }),
131 browser: None,
132 guidance: profile.template.carries_high().then_some(profile.harness),
133 graphify: profile.has(Extra::Graphify),
134 }
135 }
136
137 /// The container the window of a desktop profile is open in: the same image, permissions and
138 /// home volume as that profile's command-line container would have, under a name of its own.
139 ///
140 /// A window gets a container to itself rather than being started inside the long-lived one.
141 /// Then the container's only program is the application: "the window closed" and "the
142 /// container ended" become one fact, which the engine will tell QCode by itself, and nothing
143 /// has to ask a compositor what is on screen. The two can stand side by side on the same home
144 /// volume, so a profile's window and its command-line tabs share their settings and history.
145 ///
146 /// The bridge comes along, because the agent inside the window is an agent like any other:
147 /// it starts the same server and asks over the same socket. Only the way back is missing,
148 /// there being no prompt on a window to type an answer into.
149 ///
150 /// `None` for a profile whose harness draws in a terminal.
151 #[must_use]
152 pub fn window(workspace: &WorkspaceId, paths: &WorkspacePaths, profile: &Profile) -> Option<Self> {
153 let desktop = profile.harness.desktop()?;
154 Some(Self {
155 name: names::desktop_container(workspace.as_str(), profile.name.as_str()),
156 window: Some(desktop),
157 browser: Some(paths.browser()),
158 ..Self::profile(workspace, paths, profile)
159 })
160 }
161
162 /// What the container can see, in the order the command lists it.
163 fn mounts<'a>(&'a self, home: Option<&'a str>) -> Vec<Mount<'a>> {
164 let mut mounts = vec![
165 Mount { source: MountSource::Path(&self.code), target: Path::new(CODE_DIR), access: Access::ReadWrite },
166 Mount {
167 source: MountSource::Path(&self.assets),
168 target: Path::new(ASSETS_DIR),
169 access: self.assets_access,
170 },
171 ];
172 if let Some(home) = home {
173 mounts.push(Mount {
174 source: MountSource::Volume(home),
175 target: Path::new(HOME_DIR),
176 access: Access::ReadWrite,
177 });
178 }
179 if let Some(browser) = &self.browser {
180 // Writable, because the address the application wants opened is written here; it is
181 // the only thing of the machine this container may write to besides the workspace.
182 mounts.push(Mount {
183 source: MountSource::Path(browser),
184 target: Path::new(desktop::signin::OPEN_DIR),
185 access: Access::ReadWrite,
186 });
187 }
188 if let Some(bridge) = &self.bridge {
189 mounts.push(Mount {
190 source: MountSource::Path(&bridge.folder),
191 target: Path::new(MCP_DIR),
192 access: Access::ReadOnly,
193 });
194 }
195 mounts
196 }
197
198 /// The command that creates the container, without starting it. The container carries the
199 /// [`digest`](Self::digest) of this plan in [`PLAN_LABEL`].
200 #[must_use]
201 pub fn create(&self, engine: &Engine, user: HostUser) -> EngineCommand {
202 let digest = self.digest(engine, user);
203 self.create_labelled(engine, user, &[(PLAN_LABEL, &digest)])
204 }
205
206 /// What tells this plan from any other: a digest of the command that creates its container,
207 /// label aside. A container made from another plan (other mounts, another network, a bridge
208 /// it did not have yet) carries another digest, which is how `ensure_running` knows to make
209 /// it again.
210 #[must_use]
211 pub fn digest(&self, engine: &Engine, user: HostUser) -> String {
212 let command = self.create_labelled(engine, user, &[]);
213 let mut spelled = command.program.as_os_str().to_string_lossy().into_owned();
214 for arg in &command.args {
215 spelled.push('\0');
216 spelled.push_str(&arg.to_string_lossy());
217 }
218 format!("{:016x}", base::digest(&spelled))
219 }
220
221 fn create_labelled(&self, engine: &Engine, user: HostUser, labels: &[(&str, &str)]) -> EngineCommand {
222 let home = self.home.as_ref().map(Home::volume);
223 let mounts = self.mounts(home.as_deref());
224 engine.create_container(&ContainerCreate {
225 name: &self.name,
226 hostname: names::HOSTNAME,
227 labels,
228 image: &self.image,
229 mounts: &mounts,
230 network: self.network,
231 user,
232 workdir: Some(Path::new(CODE_DIR)),
233 command: KEEP_ALIVE,
234 })
235 }
236
237 /// The command that runs `command` inside the container, attached to a terminal.
238 ///
239 /// This is the only command a tab ever spawns, which is what keeps every tab inside a
240 /// container: the program is an argument of the engine, never something started here.
241 #[must_use]
242 pub fn enter(&self, engine: &Engine, command: &[&str]) -> EngineCommand {
243 self.enter_with(engine, command, &[])
244 }
245
246 /// [`ContainerPlan::enter`] with environment variables for the program, as `(name, value)`.
247 #[must_use]
248 pub fn enter_with(&self, engine: &Engine, command: &[&str], env: &[(&str, &str)]) -> EngineCommand {
249 engine.exec_with_env(&Exec { container: &self.name, command }, env)
250 }
251
252 /// The command that opens the window, given what this machine offers it and, for the engine
253 /// that needs one, the seccomp profile at `seccomp`.
254 ///
255 /// `None` for a plan that opens no window. Every option and why it is there is in
256 /// [`crate::engine::RunWindow`] and in [`crate::desktop`]; the shape of the call is the trial's, measured.
257 #[must_use]
258 pub fn open_window(
259 &self,
260 engine: &Engine,
261 user: HostUser,
262 display: &Display,
263 seccomp: Option<&Path>,
264 ) -> Option<EngineCommand> {
265 let desktop = self.window?;
266 let home = self.home.as_ref().map(Home::volume);
267 let mounts = self.mounts(home.as_deref());
268 let program = desktop.command_line(CODE_DIR);
269 let command: Vec<&str> = program.iter().map(String::as_str).collect();
270 let once = RunOnce {
271 image: &self.image,
272 mounts: &mounts,
273 network: self.network,
274 user,
275 workdir: Some(Path::new(CODE_DIR)),
276 command: &command,
277 };
278 Some(desktop::run_command(engine, &self.name, once, display, seccomp))
279 }
280
281 /// The command that asks the open window to show itself: the application started a second time
282 /// inside the container it already runs in.
283 ///
284 /// A Wayland application cannot raise its own window without an activation token from the
285 /// compositor, and QCode, being a terminal application, has none to hand it. What this does is
286 /// what a second start of an editor of this family does: it finds the instance already running
287 /// on the same data folder, tells it, and exits. Whether the window then comes forward or is
288 /// only marked as asking for attention is the compositor's to decide, and it differs between
289 /// them; either way the person is pointed at the window they asked for.
290 ///
291 /// `None` for a plan that opens no window.
292 #[must_use]
293 pub fn raise_window(&self, engine: &Engine) -> Option<EngineCommand> {
294 let desktop = self.window?;
295 let program = desktop.command_line(CODE_DIR);
296 let command: Vec<&str> = program.iter().map(String::as_str).collect();
297 Some(engine.exec_without_terminal(&Exec { container: &self.name, command: &command }))
298 }
299
300 /// The command that ends, inside this container, every process the tab whose token is `token`
301 /// started: its harness, the relay in front of it, and whatever those started in turn. Each of
302 /// them carries the tab's token in its environment, and nothing else does.
303 ///
304 /// Closing a tab stops the engine's command that was attached to its terminal, but the engine
305 /// leaves what that command started inside the container running: in the endurance trial
306 /// (2026-09-24) a closed tab's Claude Code and relay were still running twenty-five minutes later,
307 /// able to go on working and spending, and holding the relay's port so that the tab could not
308 /// be opened again.
309 #[must_use]
310 pub fn end_tab(&self, engine: &Engine, token: &str) -> EngineCommand {
311 let script = "for p in /proc/[0-9]*; do \
312 tr '\\000' '\\n' < \"$p/environ\" 2>/dev/null | grep -qxF \"$1=$2\" \
313 && kill -TERM \"${p#/proc/}\" 2>/dev/null; done; true";
314 let command = ["sh", "-c", script, "sh", crate::bridge::TOKEN_VARIABLE, token];
315 engine.exec_without_terminal(&Exec { container: &self.name, command: &command })
316 }
317
318 /// The command that shows `address` in the sign-in window, inside the container the window
319 /// is open in: `localhost` there is where the application waits for the sign-in to come
320 /// back. Started with the application's own flags, so it reaches the same compositor.
321 ///
322 /// `None` for a plan that opens no window.
323 #[must_use]
324 pub fn open_page(&self, engine: &Engine, address: &str) -> Option<EngineCommand> {
325 let desktop = self.window?;
326 let command = desktop::signin::page_command(desktop.flags, address);
327 let command: Vec<&str> = command.iter().map(String::as_str).collect();
328 Some(engine.exec_without_terminal(&Exec { container: &self.name, command: &command }))
329 }
330}
331
332/// An engine command that did not do what was asked, kept as text so a tab can show the engine's
333/// own words.
334///
335/// The engine layer's error carries an [`std::io::Error`], which cannot be cloned or sent
336/// through a message, so it is turned into its words at this boundary.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct LaunchFailure {
339 /// The command that was run, written out as program and arguments.
340 pub command: String,
341 /// Everything the engine said, or the reason it could not be started.
342 pub output: String,
343 /// Whether what failed is that the engine does not have the profile's image, which was asked
344 /// before anything was made: the one failure the tab can put right by itself, by building it.
345 pub image_missing: bool,
346}
347
348impl LaunchFailure {
349 /// The failure of a command that could not even be started in a pseudo-terminal, which is
350 /// how a tab learns that the engine binary went missing between two frames.
351 #[must_use]
352 pub fn spawn(command: &EngineCommand, error: &std::io::Error) -> Self {
353 Self { command: written(command), output: error.to_string(), image_missing: false }
354 }
355
356 /// The failure of the base image build, in the words the person is shown.
357 ///
358 /// A build context that could not be written has no engine command behind it, so the
359 /// command is left empty and only the machine's words are shown.
360 pub(super) fn base(failure: &base::Failure) -> Self {
361 match failure {
362 base::Failure::Host(error) => {
363 Self { command: String::new(), output: error.to_string(), image_missing: false }
364 }
365 base::Failure::Engine(error) => Self::from(error),
366 }
367 }
368
369 /// The failure of this machine itself rather than of the engine, which has no command to show
370 /// above its words.
371 pub(super) fn from_host(error: &std::io::Error) -> Self {
372 Self { command: String::new(), output: error.to_string(), image_missing: false }
373 }
374
375 /// The failure of an engine command, in the words the person is shown.
376 pub(super) fn from(error: &EngineError) -> Self {
377 match error {
378 EngineError::NotRunnable { command, error } => {
379 Self { command: written(command), output: error.to_string(), image_missing: false }
380 }
381 EngineError::Failed(failure) => {
382 Self { command: written(&failure.command), output: failure.output.clone(), image_missing: false }
383 }
384 EngineError::Cancelled { command } => {
385 Self { command: written(command), output: String::new(), image_missing: false }
386 }
387 EngineError::TimedOut { command, after } => {
388 Self { command: written(command), output: run::timed_out(command, *after), image_missing: false }
389 }
390 }
391 }
392}
393
394/// A command written out the way it would be typed, for showing beside its output.
395fn written(command: &EngineCommand) -> String {
396 let mut text = command.program.display().to_string();
397 for arg in &command.args {
398 text.push(' ');
399 text.push_str(&arg.to_string_lossy());
400 }
401 text
402}
403
404/// Makes sure the container of `plan` is up, creating it when it has never existed and starting
405/// it when it is only stopped.
406///
407/// A plain shell container is made from the base image, which a machine that has never built a
408/// profile does not have yet; it is built here first, so the first tab ever opened comes up
409/// rather than failing with "no such image". A tab has no log, so the build's own lines go
410/// nowhere and the tab shows that it is starting for as long as the build takes; a failure is
411/// shown with the engine's words like any other. A profile container is made from the profile's
412/// own image, which the profiles screen builds, so nothing is built here for one.
413///
414/// A profile container's home volume is made along with the container, and a home made here is
415/// given the profile's stored login before the container starts, so the first tab opened in a
416/// workspace does not ask for a login that was made already. A home that was there before the
417/// container is left as it is: it is the workspace's own copy, and only the person may have it
418/// rewritten.
419///
420/// A container takes its mounts and its network when it is made, so a stopped container made
421/// from another plan than this one (its [`PLAN_LABEL`] says so) is made again: removed and
422/// created anew, which keeps every named volume and so the home with the harness's login,
423/// history and settings in it. What was only in the container itself, outside the home and the
424/// workspace's folders, goes with it, as it does when the container is removed by hand. A running
425/// container is never made again, because tabs are working in it; it keeps its old plan until
426/// it is next stopped, which happens by the latest when no QCode is open.
427///
428/// A frozen container is woken instead, for the same reason and with more to lose: it holds every
429/// tab of the profile, each with its own process and its own terminal, so a person coming back to
430/// it finds the work exactly as it was. Making it again would end all of them at once, so an
431/// engine that will not wake it is a failure the person is shown rather than a container QCode
432/// throws away and makes anew.
433///
434/// This runs engine commands and waits for them, so it belongs on a background thread: give it
435/// to `Command::perform`, never to `update` or `view`.
436///
437/// # Errors
438///
439/// The engine's own words when it cannot be started, or refuses to build the base image, to
440/// create or start the container, or to copy the login in; the machine's when the bridge's
441/// folder cannot be made.
442pub fn ensure_running(engine: &Engine, plan: &ContainerPlan, user: HostUser) -> Result<(), LaunchFailure> {
443 ensure_running_noting(engine, plan, user).map(|_| ())
444}
445
446/// [`ensure_running`], answering the administrator's command that could not be run again when the
447/// profile's image was rebuilt under a workspace that installed things of its own: the workspace
448/// then stays on the image it had, and the person is to be told which command it was.
449///
450/// Before a stopped container is made again, what its administrator changed in the system is
451/// committed to the workspace's own image, and the container is made again from that one
452/// (the `keep` module). Nothing of it reaches the profile or another workspace.
453///
454/// # Errors
455///
456/// As [`ensure_running`].
457pub fn ensure_running_noting(
458 engine: &Engine,
459 plan: &ContainerPlan,
460 user: HostUser,
461) -> Result<Option<String>, LaunchFailure> {
462 // A container QCode cannot ask after is one that is not there: both engines answer an
463 // unknown name with an error rather than with a state.
464 let state = run::capture(&engine.container_state(&plan.name)).map(|word| ContainerState::parse(&word)).ok();
465 let exists = match state {
466 Some(ContainerState::Running) => return Ok(None),
467 // A frozen container is not a stopped one: it is up, with every tab in it still running
468 // and everything they wrote still in memory. So it is woken and nothing else, the same
469 // answer a running container gives. Making it again would end all of them at once, so an
470 // engine that refuses to wake it is said to the person rather than acted on.
471 Some(ContainerState::Paused) => {
472 run::capture(&engine.unpause_container(&plan.name)).map_err(|error| LaunchFailure::from(&error))?;
473 return Ok(None);
474 }
475 Some(_) => {
476 let Stopped { current, replaced, before } = stopped(engine, plan, user);
477 if !current {
478 // What the administrator installed goes into the workspace's image before the
479 // container that holds it goes.
480 keep::keep(engine, plan).map_err(|error| LaunchFailure::from(&error))?;
481 run::capture(&engine.remove_container(&plan.name)).map_err(|error| LaunchFailure::from(&error))?;
482 // The image a rebuild left without a name goes with its last container; while
483 // another container still holds it the engine refuses, and that one takes it.
484 if let Some(old) = replaced {
485 let _ = run::capture(&engine.remove_unused_image(&old));
486 }
487 if keep::current(engine, plan) != before {
488 keep::let_go(engine, before.as_deref());
489 }
490 }
491 current
492 }
493 None => false,
494 };
495 let mut behind = None;
496 if !exists {
497 // A profile's image is QCode's to build, and only the person can say it may take the
498 // minutes that takes; so an engine without it is asked before anything is made, and the
499 // tab offers the build rather than failing on `create` with the engine's riddle about it.
500 if plan.home.is_some()
501 && let Err(error) = run::capture(&engine.image_exists(&plan.image))
502 {
503 let missing = matches!(&error, EngineError::Failed(failure)
504 if known::recognise(&failure.output) == Some(Known::ImageMissing));
505 return Err(LaunchFailure { image_missing: missing, ..LaunchFailure::from(&error) });
506 }
507 if plan.image == names::BASE_IMAGE {
508 base::ensure(engine, &|| false, &mut |_| {}).map_err(|failure| LaunchFailure::base(&failure))?;
509 }
510 // An engine mounting a folder that is not there fails, or makes it as its own user; the
511 // folder is made here, as the person.
512 if let Some(bridge) = &plan.bridge {
513 std::fs::create_dir_all(&bridge.folder).map_err(|error| LaunchFailure {
514 command: String::new(),
515 output: format!("{}: {error}", bridge.folder.display()),
516 image_missing: false,
517 })?;
518 }
519 // Asked before the creation, because the creation is what makes the volume.
520 let mut new_home = None;
521 if let Some(home) = &plan.home
522 && !identity::home_exists(engine, home).map_err(|error| LaunchFailure::from(&error))?
523 {
524 new_home = Some(home);
525 }
526 let made = match keep::choose(engine, plan, user) {
527 keep::Made::Profile => plan.clone(),
528 keep::Made::Own(image) => ContainerPlan { image, ..plan.clone() },
529 keep::Made::Behind { image, failed } => {
530 behind = Some(failed);
531 ContainerPlan { image, ..plan.clone() }
532 }
533 };
534 run::capture(&made.create(engine, user)).map_err(|error| LaunchFailure::from(&error))?;
535 if let Some(home) = new_home {
536 identity::first_fill(engine, home, user).map_err(|error| LaunchFailure::from(&error))?;
537 }
538 }
539 run::capture(&engine.start_container(&plan.name)).map_err(|error| LaunchFailure::from(&error))?;
540 name_the_user(engine, &plan.name, user);
541 Ok(behind)
542}
543
544/// Gives the person a name inside the container `container` on the engine whose daemon runs it as
545/// the ids QCode hands it; does nothing on the engine that maps the person into the container's own
546/// user namespace, nor for a host that has no POSIX ids to map.
547///
548/// On docker the container is created with `--user uid:gid` and the image names nobody but the uid
549/// it was built with, so a person whose uid is another one comes up as a number: `whoami` cannot
550/// answer, `git commit` refuses to write an author, and Node's `os.userInfo()` throws on it. The
551/// home directory and the person's own files are right, so this is the whole of what is missing.
552/// Whether the uid needs a name at all is asked of the container's own password file rather than
553/// of the image's build: the shell looks for the uid in its own field and writes nothing into a
554/// container that already knows the person, which is every container of an image made for the uid
555/// the base was built with.
556///
557/// An engine that will not write it is not said on screen and does not stop the tab: a name is
558/// worth less than a tab that comes up, and the tab works either way.
559fn name_the_user(engine: &Engine, container: &str, user: HostUser) {
560 if !engine.needs_a_user_entry() {
561 return;
562 }
563 let HostUser::Ids { uid, gid } = user else { return };
564 let (uid, gid) = (uid.to_string(), gid.to_string());
565 let command = ["sh", "-c", base::paths::NAME_THE_USER, "sh", &uid, &gid];
566 let _ = run::capture(&engine.exec_as_root_without_terminal(&Exec { container, command: &command }));
567}
568
569/// Makes a stopped container of a profile anew when it is not the one its plan asks for now —
570/// the profile's image was built again since, or the profile's plan changed — and starts it;
571/// answers whether it did, and the administrator's command that could not be run again on a
572/// rebuilt image as [`ensure_running_noting`] does.
573///
574/// The page of a new tab reads the conversations out of a profile's container and starts a
575/// stopped one to do it, and a tab then finds it running and takes it as it is. Without this
576/// the container made from the old image or the old plan would be started there and never
577/// replaced: a rebuild, a changed network or what the administrator installed would reach no
578/// workspace that had run the profile before. Nothing of the person's is lost: the conversations
579/// are in the home volume, which the new one mounts, and what the administrator changed in the
580/// system is kept first (the `keep` module).
581///
582/// A frozen container is woken, never made anew, and answered as renewed: it is up, and making a
583/// new one beside it would end every tab in it.
584///
585/// Runs engine commands and waits for them, so it belongs on a background thread.
586pub fn renew_stale(engine: &Engine, plan: &ContainerPlan, user: HostUser) -> (bool, Option<String>) {
587 let state = run::capture(&engine.container_state(&plan.name)).map(|word| ContainerState::parse(&word)).ok();
588 // A frozen container is up, so it is never made anew: every tab in it is still running and
589 // everything they hold is still in memory, and a container made from a newer image or plan
590 // would end all of them at once. It is not started either, which would be refused; it is
591 // woken, so the conversation this page is about to read is read from the same container the
592 // tabs are in. A frozen container whose plan has gone stale keeps it, exactly as a running
593 // one does, until it is next stopped.
594 if matches!(state, Some(ContainerState::Paused)) {
595 return match run::capture(&engine.unpause_container(&plan.name)) {
596 Ok(_) => (true, None),
597 // Left frozen: the engine's own words are the caller's to show, and a container QCode
598 // cannot wake is not one it may throw away either.
599 Err(_) => (false, None),
600 };
601 }
602 let stopped_now = matches!(state, Some(state) if state != ContainerState::Running);
603 if !stopped_now || stopped(engine, plan, user).current {
604 return (false, None);
605 }
606 match ensure_running_noting(engine, plan, user) {
607 Ok(behind) => (true, behind),
608 Err(_) => (false, None),
609 }
610}
611
612/// What a stopped container of `plan` is, against what the plan asks for now.
613struct Stopped {
614 /// It is the container the plan asks for, and can be started as it is.
615 current: bool,
616 /// The image it was made from, when a rebuild has replaced that one since.
617 replaced: Option<String>,
618 /// The workspace's own image of the plan as it is before anything is made again.
619 before: Option<String>,
620}
621
622fn stopped(engine: &Engine, plan: &ContainerPlan, user: HostUser) -> Stopped {
623 // The plan as the stopped container should have been made: from the workspace's own image
624 // when it has one made on the profile's image as it is.
625 let (made, fresh) = as_made(engine, plan);
626 let made_from = run::capture(&engine.container_label(&plan.name, PLAN_LABEL)).unwrap_or_default();
627 // The workspace's own image is never an image a rebuild left behind, whatever the profile's
628 // image under it has become: it is what the new container is made from.
629 let before = keep::current(engine, plan);
630 let replaced = replaced_image(engine, &made).filter(|old| before.as_deref() != Some(old.trim()));
631 let current = fresh && made_from.trim() == made.digest(engine, user) && replaced.is_none();
632 Stopped { current, replaced, before }
633}
634
635/// The plan as the container of `plan` is to be made: from the workspace's own image when it has
636/// one made on the profile's image as it is now; and whether it has none or that one. A container
637/// made from the workspace's image is not made from a replaced image, whatever the profile's
638/// image under it has become.
639fn as_made(engine: &Engine, plan: &ContainerPlan) -> (ContainerPlan, bool) {
640 match keep::standing(engine, plan) {
641 Some((own, true)) => (ContainerPlan { image: own, ..plan.clone() }, true),
642 Some((_, false)) => (plan.clone(), false),
643 None => (plan.clone(), true),
644 }
645}
646
647/// The image a stopped container of a profile was made from, when the profile's image has been
648/// built again since: the plan names the image, and a rebuild keeps the name while the image
649/// under it changes, so the plan's digest alone would start the old container again and the
650/// rebuild would never reach a workspace. `None` when the image is the same, and when either
651/// answer cannot be had: a container is then kept as it was before QCode asked.
652fn replaced_image(engine: &Engine, plan: &ContainerPlan) -> Option<String> {
653 plan.home.as_ref()?;
654 let made_from = run::capture(&engine.container_image(&plan.name)).ok()?;
655 let now = run::capture(&engine.image_exists(&plan.image)).ok()?;
656 let (made_from, now) = (made_from.trim(), now.trim());
657 (!made_from.is_empty() && !now.is_empty() && made_from != now).then(|| made_from.to_owned())
658}
659
660/// What opening a window came to.
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub enum Window {
663 /// A container was started and the window is on its way up.
664 Opened,
665 /// A container of that name was already running, so the tab took that window rather than
666 /// opening a second one. This is what a QCode that crashed with a window open leaves behind,
667 /// what a second tab of the same profile would otherwise duplicate, and what a frozen
668 /// container is treated as once it has been woken.
669 Adopted,
670}
671
672/// Opens the window of `plan` and says whether it was started or found already up.
673///
674/// A container of the same name that is not running is in nobody's way and is removed: it is the
675/// remains of a window that closed while no QCode was watching, or of one a closing QCode stopped,
676/// and the window is opened fresh. Nothing of the person's is in it — the settings, the history and
677/// the login are all in the home volume, which the new container mounts.
678///
679/// A frozen container is not such a remainder: its window is still there, with the work in it, so
680/// it is woken and its window taken rather than taken away. A window profile is never frozen by
681/// QCode's own rules, so this is here for a container something else froze.
682///
683/// Runs engine commands and waits for them, so it belongs on a background thread.
684///
685/// # Errors
686///
687/// The engine's own words when it cannot be started, or refuses to wake, remove or start the
688/// container, or to run the new one; and the machine's when the seccomp profile cannot be written.
689pub fn open_window(
690 engine: &Engine,
691 plan: &ContainerPlan,
692 user: HostUser,
693 display: &Display,
694) -> Result<Window, LaunchFailure> {
695 let state = run::capture(&engine.container_state(&plan.name)).map(|word| ContainerState::parse(&word)).ok();
696 match state {
697 Some(ContainerState::Running) => return Ok(Window::Adopted),
698 Some(ContainerState::Paused) => {
699 run::capture(&engine.unpause_container(&plan.name)).map_err(|error| LaunchFailure::from(&error))?;
700 return Ok(Window::Adopted);
701 }
702 Some(_) => {
703 run::capture(&engine.remove_container(&plan.name)).map_err(|error| LaunchFailure::from(&error))?;
704 }
705 None => {}
706 }
707 // The folders the window mounts have to be there before the container starts: an engine
708 // refuses to mount a path that does not exist. One is where the window writes the addresses
709 // it wants opened, the other is where the bridge's socket and server live.
710 for folder in [plan.browser.as_ref(), plan.bridge.as_ref().map(|bridge| &bridge.folder)].into_iter().flatten() {
711 std::fs::create_dir_all(folder).map_err(|error| LaunchFailure::from_host(&error))?;
712 }
713 let seccomp = if engine.needs_sandbox_profile() {
714 Some(desktop::seccomp::file().map_err(|error| LaunchFailure::from_host(&error))?)
715 } else {
716 None
717 };
718 let Some(command) = plan.open_window(engine, user, display, seccomp.as_deref()) else {
719 return Ok(Window::Opened);
720 };
721 run::capture(&command).map_err(|error| LaunchFailure::from(&error))?;
722 // In the window's own container rather than the one that prepared it: that one is taken away
723 // at once, and the map with it, while this one lives as long as the window.
724 if plan.graphify {
725 build_map(engine, &plan.name, &plan.code);
726 }
727 Ok(Window::Opened)
728}
729
730/// Gives the home of `plan`'s window the profile's stored login, before the window opens, when
731/// the home has no login of its own.
732///
733/// A window's container is made and started in one go with the application as its only program,
734/// so this cannot wait for [`ensure_running`], which gives a terminal's home its login when the
735/// home is first made. It runs every time a window opens instead: a workspace made before the
736/// profile was signed in gets the login the next time its window opens, and one that already has
737/// a login, its own or an earlier copy, keeps it untouched.
738///
739/// Runs engine commands and waits for them, so it belongs on a background thread.
740///
741/// # Errors
742///
743/// The engine's own words when it cannot list its volumes or refuses the copy.
744pub fn give_window_login(engine: &Engine, plan: &ContainerPlan, user: HostUser) -> Result<(), LaunchFailure> {
745 let (Some(_), Some(home)) = (plan.window, &plan.home) else { return Ok(()) };
746 identity::first_fill(engine, home, user).map_err(|error| LaunchFailure::from(&error))
747}
748
749/// What was made ready for a window before it opened: the agent's own answers written into its
750/// home, the bridge's server registered in its settings, and its instruction files in the
751/// workspace; each of them either done or why not.
752#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct Prepared {
754 /// The agent's approvals and permission grants, for a window's home whatever else it holds.
755 pub approvals: Result<(), String>,
756 /// The bridge's registration.
757 pub bridge: Result<(), Unregistered>,
758 /// The instruction files, for a profile on QCode high.
759 pub guidance: Result<(), Unguided>,
760}
761
762/// Makes `plan`'s window ready before it opens, from a container made for that and nothing else:
763/// writes what the window's agent may do without asking into the home on its volume, registers the
764/// bridge's server in the settings there for the tab whose token is `token`, and, for a profile on
765/// QCode high, brings its instruction files in the workspace up to date the way [`guide`] does for
766/// a terminal's container.
767///
768/// The agent's answers come first, since the application reads its database as it starts and a
769/// window that asks about every file read, every command and every edit is the one thing a
770/// container is not supposed to cost. They are written for every window with a home, under every
771/// template and into a home that was signed in to by hand, and merged into what is there rather
772/// than written over it, so the settings a person made in the window are kept.
773///
774/// A window's own container cannot be written to from the inside: it is created and started in one
775/// go with the application as its only program, so by the time it is there the settings have been
776/// read. This runs before the window opens instead, on the same volume and workspace and from the
777/// same image, so what it writes is what the application finds. The container goes away again
778/// whatever happened, and one left behind by an interrupted run is removed first, so it cannot
779/// block the next.
780///
781/// Runs engine commands and waits for them, so it belongs on a background thread.
782#[must_use]
783pub fn prepare_window(engine: &Engine, plan: &ContainerPlan, user: HostUser, token: &str) -> Prepared {
784 let mut prepared = Prepared { approvals: Ok(()), bridge: Ok(()), guidance: Ok(()) };
785 let Some(home) = &plan.home else { return prepared };
786 let name = home.settings_container();
787 let volume = home.volume();
788 let mut mounts =
789 vec![Mount { source: MountSource::Volume(&volume), target: Path::new(HOME_DIR), access: Access::ReadWrite }];
790 if plan.guidance.is_some() {
791 mounts.push(Mount {
792 source: MountSource::Path(&plan.code),
793 target: Path::new(CODE_DIR),
794 access: Access::ReadWrite,
795 });
796 }
797 let create = engine.create_container(&ContainerCreate {
798 name: &name,
799 hostname: names::HOSTNAME,
800 labels: &[],
801 image: &plan.image,
802 mounts: &mounts,
803 network: Network::None,
804 user,
805 workdir: None,
806 command: KEEP_ALIVE,
807 });
808 let remove = engine.remove_container(&name);
809 let _ = run::capture(&remove);
810 let up = run::capture(&create).and_then(|_| run::capture(&engine.start_container(&name)));
811 match up {
812 Ok(_) => {
813 prepared.approvals = answer(engine, &desktop::login::approve_command(), &name);
814 if let Some(bridge) = &plan.bridge {
815 prepared.bridge = config::register(engine, &name, bridge.harness, token);
816 }
817 if let Some(harness) = plan.guidance {
818 prepared.guidance = guide(engine, &name, &plan.code, harness, plan.graphify);
819 }
820 }
821 Err(error) => {
822 let words = config::words(&error);
823 prepared.approvals = Err(words.clone());
824 if plan.bridge.is_some() {
825 prepared.bridge = Err(Unregistered::Engine(words.clone()));
826 }
827 if plan.guidance.is_some() {
828 prepared.guidance = Err(Unguided::Graphify(words));
829 }
830 }
831 }
832 let _ = run::capture(&remove);
833 prepared
834}
835
836/// Runs `command` in the container `container` and answers the engine's own words when it refuses.
837fn answer(engine: &Engine, command: &[String], container: &str) -> Result<(), String> {
838 let command: Vec<&str> = command.iter().map(String::as_str).collect();
839 run::capture(&engine.exec_without_terminal(&Exec { container, command: &command }))
840 .map(|_| ())
841 .map_err(|error| config::words(&error))
842}
843
844/// Brings the instruction files of `harness` in the workspace up to date, from the running
845/// container `container` whose workspace folder is `code` on this machine; graphify's part only
846/// when `graphify` says it is in the image, since a profile that went without it has no graphify
847/// to run.
848///
849/// graphify goes first and writes its own part itself — its section, its hooks, and for some
850/// harnesses a skill in the home — by its own installer inside the container, in [`CODE_DIR`],
851/// the folder the agent works in: measured, it needs no network and a second run writes nothing
852/// new. QCode's section is written next, on this machine, into the same folder, and only when it
853/// is not already there as it would be written. The two happen under [`guidance::lock`], so two
854/// tabs coming up at once in one workspace cannot write one file over the other's.
855///
856/// Runs engine commands and writes the disk, so it belongs on a background thread.
857///
858/// # Errors
859///
860/// [`Unguided`] when graphify's installer or the engine refuses, or QCode's section cannot be
861/// written. QCode's section is still written when graphify's failed, so the agent learns of the
862/// other tabs either way.
863pub fn guide(
864 engine: &Engine,
865 container: &str,
866 code: &Path,
867 harness: HarnessKind,
868 graphify: bool,
869) -> Result<(), Unguided> {
870 let _writing = guidance::lock();
871 let install =
872 ["sh", "-c", "cd \"$1\" && exec graphify \"$2\" install", "sh", CODE_DIR, guidance::platform(harness)];
873 let graphify = if graphify {
874 run::capture(&engine.exec_without_terminal(&Exec { container, command: &install }))
875 .map(|_| ())
876 .map_err(|error| Unguided::Graphify(config::words(&error)))
877 } else {
878 Ok(())
879 };
880 let ours = guidance::write(code, harness).map(|_| ());
881 graphify.and(ours)
882}
883
884/// graphify's map of the workspace's code, relative to the workspace folder: what graphify's
885/// section and hooks send the agent to.
886pub const MAP: &str = "graphify-out/graph.json";
887
888/// Starts graphify building its map of the workspace's code in the running container `container`,
889/// whose workspace folder is `code` on this machine, when there is no map there yet; does nothing
890/// when there is one.
891///
892/// graphify's section and hooks — in the home under QCode basic, in the workspace under QCode
893/// high — tell the agent to ask the map before it reads files, but nothing builds the map, so
894/// without this the agent is pointed at a file that is not there. The map folder is kept out of
895/// git first ([`exclude_map`]). `graphify update .` builds it from the code alone: measured, it needs no network and no
896/// model. It is left running on its own inside the container and not waited for, because a large
897/// workspace takes minutes and the harness must not wait for it; its output goes nowhere, since
898/// nobody is there to read it. Once the map is there, graphify's own hooks keep it current, so a
899/// map that exists is never rebuilt here.
900///
901/// A map that fails to be built is not said on screen: graphify's section tells the agent how to
902/// build it, and the agent works without one.
903///
904/// Runs an engine command, so it belongs on a background thread.
905pub fn build_map(engine: &Engine, container: &str, code: &Path) {
906 // Before the map is asked after, so a map an earlier QCode built is kept out of git too.
907 let _ = exclude_map(code);
908 if code.join(MAP).exists() {
909 return;
910 }
911 // `flock -n` so that two tabs of one profile coming up at once, which share its container,
912 // start one build between them and not two writing the same files.
913 let build = [
914 "sh",
915 "-c",
916 "cd \"$1\" && nohup flock -n /tmp/qcode-map graphify update . </dev/null >/dev/null 2>&1 &",
917 "sh",
918 CODE_DIR,
919 ];
920 let _ = run::capture(&engine.exec_without_terminal(&Exec { container, command: &build }));
921}
922
923/// The line that keeps graphify's map folder out of git: anchored at the top of the repository, so
924/// a folder of that name deeper in the person's code is left alone.
925pub const MAP_EXCLUDED: &str = "/graphify-out/";
926
927/// Keeps graphify's map out of git when the workspace folder `code` is a git repository, and says
928/// whether a line had to be added: the line [`MAP_EXCLUDED`] in the repository's own
929/// `info/exclude`, which lives inside `.git` and is never committed, rather than in a `.gitignore`
930/// of the person's that would show up in their next commit. Measured with graphify 0.9.67 in a
931/// fresh repository, `graphify update .` leaves `graphify-out/` as an untracked folder and writes
932/// no ignore file of its own.
933///
934/// A `.git` that is a file, as in a worktree, names the folder git keeps it in; a worktree reads
935/// the exclude file of the repository it was made from, which that folder's `commondir` names.
936/// A folder that is no repository, or a line that is there already in any of its spellings, writes
937/// nothing.
938///
939/// # Errors
940///
941/// The machine's words when the exclude file cannot be read or written. Nobody is told: the map
942/// is then only an untracked folder, which is what git would show without QCode.
943pub fn exclude_map(code: &Path) -> std::io::Result<bool> {
944 let Some(git) = git_dir(code) else { return Ok(false) };
945 let info = git.join("info");
946 let file = info.join("exclude");
947 let existing = match std::fs::read_to_string(&file) {
948 Ok(text) => text,
949 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
950 Err(error) => return Err(error),
951 };
952 let spellings = ["graphify-out", "graphify-out/", "/graphify-out", MAP_EXCLUDED];
953 if existing.lines().any(|line| spellings.contains(&line.trim())) {
954 return Ok(false);
955 }
956 std::fs::create_dir_all(&info)?;
957 let gap = if existing.is_empty() || existing.ends_with('\n') { "" } else { "\n" };
958 std::fs::write(&file, format!("{existing}{gap}{MAP_EXCLUDED}\n"))?;
959 Ok(true)
960}
961
962/// The folder git keeps the repository at `code` in, if `code` is one.
963fn git_dir(code: &Path) -> Option<PathBuf> {
964 let dot = code.join(".git");
965 if dot.is_dir() {
966 return Some(dot);
967 }
968 let text = std::fs::read_to_string(&dot).ok()?;
969 let own = code.join(text.trim().strip_prefix("gitdir:")?.trim());
970 match std::fs::read_to_string(own.join("commondir")) {
971 Ok(common) => Some(own.join(common.trim())),
972 Err(_) => Some(own),
973 }
974}
975
976/// Waits for the window's container to end and answers its exit code, or `None` when the engine
977/// said nothing a code could be read from.
978///
979/// This is how the person closing the window reaches QCode: the container's only program is the
980/// application, so the container ends when the window does. It blocks for as long as the window is
981/// open, so it belongs on a background thread of its own and on no other.
982#[must_use]
983pub fn await_window(engine: &Engine, name: &str) -> Option<u32> {
984 run::capture(&engine.wait_container(name)).ok()?.trim().lines().last()?.trim().parse().ok()
985}
986
987/// Closes the window of `name` and takes its container away.
988///
989/// The stop is what closes the window: the application answers the signal and writes its state
990/// out, which is why it is given [`desktop::WINDOW_GRACE`]. A container that had already ended is not
991/// there
992/// to stop, and saying so is not a failure; the removal is what must succeed.
993///
994/// A frozen container is woken first, because a stop is refused on one and the removal after it
995/// would take a window that never got the grace to write its state out. What is closed here is
996/// what the person asked to be closed; the wake only lets the closing happen the way it does for
997/// every other window.
998///
999/// Runs engine commands and waits for them, so it belongs on a background thread.
1000///
1001/// # Errors
1002///
1003/// The engine's own words when it will not remove the container.
1004pub fn close_window(engine: &Engine, name: &str) -> Result<(), LaunchFailure> {
1005 // Closing happens twice over: the person presses Close, and the tab's own wait on the
1006 // container answers the moment it is gone and clears up after it. Whichever arrives second
1007 // finds nothing, and must not report the first one's work as a failure — docker refuses to
1008 // remove a container it does not know, where podman shrugs.
1009 let Ok(word) = run::capture(&engine.container_state(name)) else { return Ok(()) };
1010 if matches!(ContainerState::parse(&word), ContainerState::Paused) {
1011 let _ = run::capture(&engine.unpause_container(name));
1012 }
1013 let _ = run::capture(&engine.stop_container_within(name, desktop::WINDOW_GRACE));
1014 run::capture(&engine.remove_container(name)).map(|_| ()).map_err(|error| LaunchFailure::from(&error))
1015}
1016
1017/// Asks the open window of `plan` to show itself.
1018///
1019/// Runs an engine command and waits for it, so it belongs on a background thread.
1020///
1021/// # Errors
1022///
1023/// The engine's own words when the container is not there any more or refuses.
1024pub fn raise_window(engine: &Engine, plan: &ContainerPlan) -> Result<(), LaunchFailure> {
1025 let Some(command) = plan.raise_window(engine) else { return Ok(()) };
1026 run::capture(&command).map(|_| ()).map_err(|error| LaunchFailure::from(&error))
1027}
1028
1029/// Shows `address` in the sign-in window inside the container of `plan`, and answers whether it
1030/// is up: `false` when the image has no sign-in window, which an image built before it existed
1031/// does not.
1032///
1033/// Runs an engine command and waits for it, which takes as long as starting a program in the
1034/// container and no longer: the window is left running on its own. So it belongs on a background
1035/// thread.
1036///
1037/// # Errors
1038///
1039/// The engine's own words when the container is not there any more or refuses.
1040pub fn open_page(engine: &Engine, plan: &ContainerPlan, address: &str) -> Result<bool, LaunchFailure> {
1041 let Some(command) = plan.open_page(engine, address) else { return Ok(false) };
1042 let said = run::capture(&command).map_err(|error| LaunchFailure::from(&error))?;
1043 Ok(!said.contains(desktop::signin::NO_BROWSER))
1044}
1045
1046/// Whether the container of `plan` is there right now, which is what a tab asks about the moment
1047/// its terminal ends.
1048///
1049/// A frozen container is there: nothing took it away, and the tabs in it are all still running.
1050/// Answering "no" would call the tab stopped and take its container off the list while the person
1051/// is away from the desk.
1052///
1053/// Runs an engine command, so it belongs on a background thread. An engine that cannot answer
1054/// says "not running", which is the answer that offers the person a way out.
1055#[must_use]
1056pub fn is_running(engine: &Engine, plan: &ContainerPlan) -> bool {
1057 run::capture(&engine.container_state(&plan.name))
1058 .is_ok_and(|word| matches!(ContainerState::parse(&word), ContainerState::Running | ContainerState::Paused))
1059}
1060
1061/// Every container the engine knows that belongs to `workspace`, in the engine's order.
1062///
1063/// Runs an engine command, so it belongs on a background thread.
1064///
1065/// # Errors
1066///
1067/// The engine's own words when it cannot be asked.
1068pub fn workspace_containers(engine: &Engine, workspace: &str) -> Result<Vec<Container>, LaunchFailure> {
1069 let listing = run::capture(&engine.list_containers()).map_err(|error| LaunchFailure::from(&error))?;
1070 let prefix = format!("qcode-{workspace}-");
1071 Ok(Container::parse_list(&listing).into_iter().filter(|container| container.name.starts_with(&prefix)).collect())
1072}
1073
1074/// Stops a container and waits for it to be stopped.
1075///
1076/// A frozen container is woken first: a stop of a paused container is refused by the engine, so the
1077/// panel's Stop would say the engine's words and leave the container as it is.
1078///
1079/// Runs an engine command, so it belongs on a background thread.
1080///
1081/// # Errors
1082///
1083/// The engine's own words when it refuses.
1084pub fn stop(engine: &Engine, name: &str) -> Result<(), LaunchFailure> {
1085 wake(engine, name);
1086 run::capture(&engine.stop_container(name)).map(|_| ()).map_err(|error| LaunchFailure::from(&error))
1087}
1088
1089/// Stops a container and starts it again.
1090///
1091/// Runs an engine command, so it belongs on a background thread. A container that was already
1092/// stopped is only started, so a restart never fails for having nothing to stop. A frozen one is
1093/// woken, for the same reason [`stop`] wakes it.
1094///
1095/// # Errors
1096///
1097/// The engine's own words when it refuses to start the container.
1098pub fn restart(engine: &Engine, name: &str) -> Result<(), LaunchFailure> {
1099 wake(engine, name);
1100 let _ = run::capture(&engine.stop_container(name));
1101 run::capture(&engine.start_container(name)).map(|_| ()).map_err(|error| LaunchFailure::from(&error))
1102}
1103
1104/// Wakes `container` when the engine says it is frozen, and says nothing about whether it woke: a
1105/// container that is not frozen is what this is asked about most of the time, and one the engine
1106/// would not wake is the caller's own work to report or not.
1107///
1108/// Runs engine commands, so it belongs on a background thread.
1109fn wake(engine: &Engine, container: &str) {
1110 let state = run::capture(&engine.container_state(container)).ok().map(|word| ContainerState::parse(&word));
1111 if matches!(state, Some(ContainerState::Paused)) {
1112 let _ = run::capture(&engine.unpause_container(container));
1113 }
1114}
1115
1116/// The engine's word for a profile's access to `Assets/`.
1117fn access(access: MountAccess) -> Access {
1118 match access {
1119 MountAccess::ReadWrite => Access::ReadWrite,
1120 MountAccess::ReadOnly => Access::ReadOnly,
1121 }
1122}
1123
1124/// The engine's word for a profile's network permission.
1125fn network(mode: NetworkMode) -> Network {
1126 match mode {
1127 NetworkMode::Full => Network::Full,
1128 NetworkMode::None => Network::None,
1129 }
1130}
1131
1132#[cfg(test)]
1133mod own_copy {
1134 //! Each workspace has its own copy of a profile's home, and nothing a workspace's container
1135 //! can reach is the profile's own: its definition file or its stored login.
1136
1137 use super::*;
1138 use crate::base::paths::NAME_THE_USER;
1139 use crate::engine::EngineKind;
1140 use crate::profile::{AccountKind, SafeName, Template};
1141
1142 fn paths(root: &Path) -> WorkspacePaths {
1143 WorkspacePaths {
1144 root: root.to_owned(),
1145 file: root.join("workspace.qcode"),
1146 code: root.join("Work"),
1147 assets: root.join("Assets"),
1148 harness: root.join("Containers").join("Harness"),
1149 }
1150 }
1151
1152 fn profile() -> Profile {
1153 Profile {
1154 name: SafeName::parse("claude-sub").expect("a safe name"),
1155 harness: HarnessKind::ClaudeCode,
1156 template: Template::Recommended,
1157 account: AccountKind::Subscription,
1158 provider: None,
1159 assets: MountAccess::ReadOnly,
1160 network: NetworkMode::Full,
1161 without: Vec::new(),
1162 os: crate::base::Os::Debian,
1163 }
1164 }
1165
1166 /// Every word of the command that makes `plan`'s container, as the engine is given it.
1167 fn words(plan: &ContainerPlan, engine: &Engine) -> Vec<String> {
1168 let user = HostUser::Ids { uid: 1000, gid: 1000 };
1169 plan.create(engine, user).args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
1170 }
1171
1172 #[test]
1173 fn quvyta_development_writes_the_workspaces_instructions_as_qcode_high_does() {
1174 // QCode high writes graphify's and QCode's sections into the workspace when a container
1175 // comes up; Quvyta development is QCode high and more, so it does too.
1176 let id = WorkspaceId::parse("firefly").expect("an id");
1177 let paths = paths(Path::new("/qcode-store/Workspaces/firefly"));
1178 for (template, guided) in [
1179 (Template::Recommended, None),
1180 (Template::High, Some(HarnessKind::ClaudeCode)),
1181 (Template::QuvytaDev, Some(HarnessKind::ClaudeCode)),
1182 ] {
1183 let plan = ContainerPlan::profile(&id, &paths, &Profile { template, ..profile() });
1184 assert_eq!(plan.guidance, guided, "{template:?}");
1185 }
1186 }
1187
1188 #[test]
1189 fn two_workspaces_of_one_profile_get_homes_of_their_own_and_reach_nothing_of_the_profiles() {
1190 let store = Path::new("/qcode-store");
1191 let profile = profile();
1192 let a = ContainerPlan::profile(
1193 &WorkspaceId::parse("firefly").expect("an id"),
1194 &paths(&store.join("Workspaces").join("firefly")),
1195 &profile,
1196 );
1197 let b = ContainerPlan::profile(
1198 &WorkspaceId::parse("serenity").expect("an id"),
1199 &paths(&store.join("Workspaces").join("serenity")),
1200 &profile,
1201 );
1202 let (home_a, home_b) = (a.home.as_ref().expect("a home").volume(), b.home.as_ref().expect("a home").volume());
1203 assert_ne!(home_a, home_b, "each workspace has its own copy");
1204 assert_ne!(a.name, b.name);
1205 assert_eq!(a.image, b.image, "both start from the one image");
1206 let login = crate::engine::names::credential_volume(profile.name.as_str());
1207 let definitions = store.join("Profiles");
1208 for engine in [Engine::new(EngineKind::Podman, "/no/podman"), Engine::new(EngineKind::Docker, "/no/docker")] {
1209 for (plan, own, other) in [(&a, &home_a, &home_b), (&b, &home_b, &home_a)] {
1210 let words = words(plan, &engine);
1211 let joined = words.join(" ");
1212 assert!(joined.contains(&format!("{own}:{HOME_DIR}")), "{:?}: {joined}", engine.kind());
1213 assert!(!joined.contains(other.as_str()), "{:?}: the other workspace's home: {joined}", engine.kind());
1214 assert!(!joined.contains(&login), "{:?}: the stored login is never mounted: {joined}", engine.kind());
1215 assert!(
1216 !joined.contains(&definitions.display().to_string()),
1217 "{:?}: the definition files are never mounted: {joined}",
1218 engine.kind()
1219 );
1220 }
1221 // The one command that reads the stored login copies it into the home, and reads it
1222 // only: the login's volume is mounted read-only.
1223 let rewrite = identity::rewrite(
1224 &engine,
1225 a.home.as_ref().expect("a home"),
1226 HostUser::Ids { uid: 1000, gid: 1000 },
1227 crate::desktop::login::Fill::Keep,
1228 );
1229 let create: Vec<String> =
1230 rewrite.create.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect();
1231 let mount = create
1232 .iter()
1233 .find(|word| word.starts_with(&format!("{login}:")))
1234 .unwrap_or_else(|| panic!("the courier reads the login: {create:?}"));
1235 assert!(mount.ends_with(":ro") || mount.contains(":ro,"), "{:?}: {mount}", engine.kind());
1236 let copy: Vec<String> = rewrite.copy.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect();
1237 assert!(copy.iter().any(|script| script.ends_with(&format!(" {HOME_DIR}/; fi"))), "{copy:?}");
1238 }
1239 }
1240
1241 /// A stand-in engine that writes down every call and lists `volumes` when asked for them.
1242 fn recording(name: &str, volumes: &str) -> (Engine, PathBuf, PathBuf) {
1243 let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
1244 let folder = std::env::temp_dir().join(format!("qcode-plan-{name}-{stamp}"));
1245 std::fs::create_dir_all(&folder).expect("a folder");
1246 let (binary, calls) = (folder.join("engine"), folder.join("calls"));
1247 let script = format!(
1248 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{calls}'\n[ \"$1 $2\" = 'volume ls' ] && {{ printf '{volumes}'; exit 0; }}\nexit 0\n",
1249 calls = calls.display(),
1250 );
1251 std::fs::write(&binary, script).expect("the stand-in engine");
1252 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
1253 (Engine::new(EngineKind::Podman, &binary), calls, folder)
1254 }
1255
1256 fn window_profile() -> Profile {
1257 Profile { name: SafeName::parse("anti").expect("safe"), harness: HarnessKind::AntigravityIde, ..profile() }
1258 }
1259
1260 #[test]
1261 fn a_window_is_given_the_profiles_login_before_it_opens_and_never_over_one_of_its_own() {
1262 let (engine, calls, folder) = recording("window-login", "qcode-cred-anti\\nqcode-home-firefly-anti\\n");
1263 let id = WorkspaceId::parse("firefly").expect("an id");
1264 let plan = ContainerPlan::window(&id, &paths(&folder), &window_profile()).expect("a window");
1265 give_window_login(&engine, &plan, HostUser::Ids { uid: 1000, gid: 1000 }).expect("given");
1266 let written = std::fs::read_to_string(&calls).expect("calls");
1267 let create = written
1268 .lines()
1269 .find(|call| call.starts_with("create --name qcode-refresh-firefly-anti "))
1270 .unwrap_or_else(|| panic!("a courier carries the login: {written}"));
1271 assert!(create.contains("qcode-cred-anti:/qcode-credentials:ro"), "{create}");
1272 assert!(create.contains("qcode-home-firefly-anti:/home/qcode:rw"), "{create}");
1273 // Every opening, even of a home that was there before, and only where it has no login.
1274 assert!(written.contains("exec qcode-refresh-firefly-anti sh -c "), "{written}");
1275 // The program runs to many lines; the word after its last one is the kind of copy.
1276 assert!(written.contains("process.exit(2);\n keep\n"), "{written}");
1277 assert!(written.lines().any(|call| call == "rm --force qcode-refresh-firefly-anti"), "{written}");
1278 let _ = std::fs::remove_dir_all(&folder);
1279 }
1280
1281 #[test]
1282 fn a_window_of_a_profile_on_base_is_told_what_its_agent_may_do_before_it_opens() {
1283 // Nothing but the home: no bridge and no guidance to bring the container up for, and a
1284 // window's own container cannot be written to once the application has read its database.
1285 // So the container comes up anyway, and the answers are written into the home first.
1286 let (engine, calls, folder) = recording("window-answers", "qcode-home-firefly-anti\\n");
1287 let id = WorkspaceId::parse("firefly").expect("an id");
1288 let profile = Profile { template: Template::Base, ..window_profile() };
1289 let plan = ContainerPlan::window(&id, &paths(&folder), &profile).expect("a window");
1290 let prepared = prepare_window(&engine, &plan, HostUser::Ids { uid: 1000, gid: 1000 }, "tok");
1291 assert_eq!(prepared, Prepared { approvals: Ok(()), bridge: Ok(()), guidance: Ok(()) });
1292 let written = std::fs::read_to_string(&calls).expect("calls");
1293 // The program is many lines, so what the call is holds is read out of the whole of it.
1294 let database = format!("{HOME_DIR}/{}", crate::desktop::login::DATABASE);
1295 let answered = written
1296 .find(&format!(" approve {database}"))
1297 .unwrap_or_else(|| panic!("the answers are written: {written}"));
1298 let made = written.find("create --name qcode-firefly-anti.mcp ").expect("the container is made");
1299 assert!(made < answered, "made before it is answered: {written}");
1300 assert!(written.contains("qcode-home-firefly-anti:/home/qcode:rw"), "on the home volume: {written}");
1301 assert!(written.lines().any(|call| call == "rm --force qcode-firefly-anti.mcp"), "{written}");
1302 let _ = std::fs::remove_dir_all(&folder);
1303 }
1304
1305 #[test]
1306 fn a_window_of_a_profile_never_signed_in_and_a_terminal_are_given_nothing_here() {
1307 let (engine, calls, folder) = recording("window-none", "qcode-home-firefly-anti\\n");
1308 let id = WorkspaceId::parse("firefly").expect("an id");
1309 let window = ContainerPlan::window(&id, &paths(&folder), &window_profile()).expect("a window");
1310 give_window_login(&engine, &window, HostUser::Ids { uid: 1000, gid: 1000 }).expect("nothing to give");
1311 let terminal = ContainerPlan::profile(&id, &paths(&folder), &profile());
1312 give_window_login(&engine, &terminal, HostUser::Ids { uid: 1000, gid: 1000 }).expect("not a window");
1313 let written = std::fs::read_to_string(&calls).unwrap_or_default();
1314 assert_eq!(written.lines().collect::<Vec<_>>(), ["volume ls --format {{.Name}}"], "{written}");
1315 let _ = std::fs::remove_dir_all(&folder);
1316 }
1317
1318 /// A stand-in engine that answers for a container frozen in place: its state is `paused`, the plan
1319 /// label it carries is the digest of `plan` — so the engine sees the very container the plan
1320 /// asks for, not an old one — its volumes are `volumes`, and it has no images at all, since a
1321 /// container made long ago was not made from an image this stand-in has. Every call is written
1322 /// down, and a call named in `refused` is refused in an engine's own words.
1323 ///
1324 /// A frozen container is the case that needs one: the engine has to say `paused` and QCode has
1325 /// to be watched through what it does next, on a machine with no container runtime at all.
1326 fn frozen_engine(folder: &Path, plan: &ContainerPlan, volumes: &str, refused: &str) -> (Engine, PathBuf) {
1327 let (binary, calls) = (folder.join("engine"), folder.join("calls"));
1328 let digest = plan.digest(&Engine::new(EngineKind::Podman, &binary), user());
1329 let refusing = refused
1330 .split(' ')
1331 .filter(|word| !word.is_empty())
1332 .map(|word| format!(" {word}) printf 'Error: the stand-in refuses {word}\\n' >&2; exit 125 ;;"))
1333 .collect::<Vec<String>>()
1334 .join("\n");
1335 let script = format!(
1336 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{calls}'\ncase \"$1\" in\n{refusing}\nesac\ncase \"$1\" in\n \
1337 image) printf 'Error: no such image\\n' >&2; exit 125 ;;\nesac\ncase \"$1 $2\" in\n 'volume ls') \
1338 printf '{volumes}';;\n 'container inspect') case \"$4\" in\n *Labels*) printf '{digest}\\n';;\n \
1339 *) printf 'paused\\n';;\n esac;;\nesac\nexit 0\n",
1340 calls = calls.display(),
1341 );
1342 std::fs::write(&binary, script).expect("the stand-in engine");
1343 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
1344 (Engine::new(EngineKind::Podman, &binary), calls)
1345 }
1346
1347 /// A folder of this test's own for a workspace and the stand-in engine in it.
1348 fn scratch(name: &str) -> PathBuf {
1349 let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
1350 let folder = std::env::temp_dir().join(format!("qcode-plan-{name}-{stamp}"));
1351 std::fs::create_dir_all(&folder).expect("a folder");
1352 folder
1353 }
1354
1355 /// Every call the stand-in engine was given, in order.
1356 fn recorded(calls: &Path) -> Vec<String> {
1357 std::fs::read_to_string(calls).unwrap_or_default().lines().map(str::to_owned).collect()
1358 }
1359
1360 fn user() -> HostUser {
1361 HostUser::Ids { uid: 1000, gid: 1000 }
1362 }
1363
1364 /// The plan of a profile in `folder`'s own workspace.
1365 fn profile_plan(folder: &Path) -> ContainerPlan {
1366 ContainerPlan::profile(&WorkspaceId::parse("firefly").expect("an id"), &paths(folder), &profile())
1367 }
1368
1369 /// The window plan of a desktop profile in `folder`'s own workspace.
1370 fn window_plan(folder: &Path) -> ContainerPlan {
1371 ContainerPlan::window(&WorkspaceId::parse("firefly").expect("an id"), &paths(folder), &window_profile())
1372 .expect("a window")
1373 }
1374
1375 /// A display of this test's own, so the window tests name one that need not be there.
1376 fn display(folder: &Path) -> crate::desktop::Display {
1377 crate::desktop::Display { socket: folder.join("wayland-0"), name: "wayland-0".to_owned(), device: None }
1378 }
1379
1380 /// Every call QCode must never make about a container it is only waking.
1381 const NEVER: [&str; 5] = ["rm ", "stop ", "commit ", "create ", "start "];
1382
1383 /// A frozen container is woken, and nothing about it is thrown away and made again. Every tab
1384 /// of the profile is still running in it, so a `rm`, a `stop` or a `commit` here would end a
1385 /// conversation the person was in the middle of.
1386 #[test]
1387 fn a_frozen_container_is_woken_and_never_made_again() {
1388 let folder = scratch("frozen");
1389 let plan = profile_plan(&folder);
1390 let (engine, calls) = frozen_engine(&folder, &plan, "", "");
1391 assert_eq!(ensure_running_noting(&engine, &plan, user()).expect("woken"), None, "nothing was made anew");
1392 let asked = recorded(&calls);
1393 assert!(asked.iter().any(|call| call == &format!("unpause {}", plan.name)), "{asked:?}");
1394 for never in NEVER {
1395 assert!(!asked.iter().any(|call| call.starts_with(never)), "not {never}{asked:?}");
1396 }
1397 // The same question twice: a container that has just been woken is running, so a tab asked
1398 // while its tabs are still thawing is answered the same way rather than woken again.
1399 ensure_running_noting(&engine, &plan, user()).expect("woken again");
1400 let twice = recorded(&calls);
1401 assert_eq!(twice.iter().filter(|call| call.starts_with("unpause ")).count(), 2, "woken each time: {twice:?}");
1402 let _ = std::fs::remove_dir_all(&folder);
1403 }
1404
1405 /// An engine that will not wake a frozen container is a failure the person is shown, and never
1406 /// a reason for QCode to make the container again: every tab in it would end at once.
1407 #[test]
1408 fn a_container_that_cannot_be_woken_says_so_instead_of_being_made_again() {
1409 let folder = scratch("frozen-refused");
1410 let plan = profile_plan(&folder);
1411 let (engine, calls) = frozen_engine(&folder, &plan, "", "unpause");
1412 let failure = ensure_running_noting(&engine, &plan, user()).expect_err("an engine that refuses is a failure");
1413 assert!(failure.output.contains("refuses unpause"), "{failure:?}");
1414 assert!(failure.command.contains("unpause"), "the command that failed is named: {failure:?}");
1415 let asked = recorded(&calls);
1416 for never in NEVER {
1417 assert!(!asked.iter().any(|call| call.starts_with(never)), "not {never}{asked:?}");
1418 }
1419 let _ = std::fs::remove_dir_all(&folder);
1420 }
1421
1422 /// The page of a new tab reads a profile's conversations. A frozen container holds them, and
1423 /// the tabs reading them, so it is woken rather than renewed: a container made anew from a
1424 /// newer image would end every tab in it.
1425 #[test]
1426 fn a_frozen_container_is_woken_for_the_conversations_rather_than_renewed() {
1427 let folder = scratch("frozen-renew");
1428 let plan = profile_plan(&folder);
1429 let (engine, calls) = frozen_engine(&folder, &plan, "qcode-home-firefly-claude-sub\n", "");
1430 let (renewed, behind) = renew_stale(&engine, &plan, user());
1431 assert!(renewed && behind.is_none(), "the container is left running, as one read into is");
1432 let asked = recorded(&calls);
1433 assert!(asked.iter().any(|call| call == &format!("unpause {}", plan.name)), "{asked:?}");
1434 for never in NEVER {
1435 assert!(!asked.iter().any(|call| call.starts_with(never)), "not {never}{asked:?}");
1436 }
1437 let _ = std::fs::remove_dir_all(&folder);
1438 }
1439
1440 /// A window's container that is frozen still holds that window and everything open in it, so
1441 /// opening it wakes the container and takes that window rather than taking the container away
1442 /// and starting the application over.
1443 #[test]
1444 fn a_frozen_windows_container_is_woken_and_its_window_taken() {
1445 let folder = scratch("frozen-window");
1446 let plan = window_plan(&folder);
1447 let (engine, calls) = frozen_engine(&folder, &plan, "", "");
1448 let opened = open_window(&engine, &plan, user(), &display(&folder)).expect("the window is up");
1449 assert_eq!(opened, Window::Adopted, "the window that was there is the one that is opened");
1450 let asked = recorded(&calls);
1451 assert!(asked.iter().any(|call| call == &format!("unpause {}", plan.name)), "{asked:?}");
1452 for never in NEVER {
1453 assert!(!asked.iter().any(|call| call.starts_with(never)), "not {never}{asked:?}");
1454 }
1455 let _ = std::fs::remove_dir_all(&folder);
1456 }
1457
1458 /// Closing a window gives its application the grace to write its state out, and a stop is
1459 /// refused on a frozen container; so it is woken first, and the closing that follows is the one
1460 /// the person asked for.
1461 #[test]
1462 fn a_frozen_windows_container_is_woken_before_it_is_closed() {
1463 let folder = scratch("frozen-close");
1464 let plan = window_plan(&folder);
1465 let (engine, calls) = frozen_engine(&folder, &plan, "", "");
1466 close_window(&engine, &plan.name).expect("closed");
1467 let asked = recorded(&calls);
1468 let woken = asked.iter().position(|call| call == &format!("unpause {}", plan.name)).expect("woken first");
1469 let stopped = asked.iter().position(|call| call.starts_with("stop ")).expect("then stopped");
1470 assert!(woken < stopped, "a stop is refused on a frozen container: {asked:?}");
1471 let _ = std::fs::remove_dir_all(&folder);
1472 }
1473
1474 /// A tab whose terminal ends asks whether its container is still there. A frozen one is: it
1475 /// holds every tab of the profile, and telling the tab its container was taken away would mark
1476 /// it stopped while the person is away from the desk.
1477 #[test]
1478 fn a_frozen_container_is_still_there_when_a_tabs_terminal_ends() {
1479 let folder = scratch("frozen-there");
1480 let plan = profile_plan(&folder);
1481 let (engine, _calls) = frozen_engine(&folder, &plan, "", "");
1482 assert!(is_running(&engine, &plan), "a frozen container is up, and takes nothing away");
1483 let _ = std::fs::remove_dir_all(&folder);
1484 }
1485
1486 /// The naming command as the engine is given it, all of it one line: `exec --user 0` and the
1487 /// container, then the script with the ids as its arguments.
1488 fn naming_call(container: &str, uid: u32, gid: u32) -> String {
1489 format!("exec --user 0 {container} sh -c {NAME_THE_USER} sh {uid} {gid}")
1490 }
1491
1492 /// A stand-in engine of `kind` answering for a stopped container of `plan`: it is the very
1493 /// container the plan asks for as `as_user` — its plan label is the digest of that plan — so
1494 /// bringing it up is a `start` and then whatever QCode does next, which is where the naming
1495 /// belongs, and a second call finds the container already running and is answered by it.
1496 /// Every call is written down, and a call named in `refused` is refused in an engine's own
1497 /// words.
1498 fn stopped_engine(
1499 kind: EngineKind,
1500 folder: &Path,
1501 plan: &ContainerPlan,
1502 as_user: HostUser,
1503 refused: &str,
1504 ) -> (Engine, PathBuf) {
1505 let (binary, calls, up) = (folder.join("engine"), folder.join("calls"), folder.join("up"));
1506 let digest = plan.digest(&Engine::new(kind, &binary), as_user);
1507 let refusing = refused
1508 .split(' ')
1509 .filter(|word| !word.is_empty())
1510 .map(|word| format!(" {word}) printf 'Error: the stand-in refuses {word}\\n' >&2; exit 125 ;;"))
1511 .collect::<Vec<String>>()
1512 .join("\n");
1513 let script = format!(
1514 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{calls}'\ncase \"$1\" in\n{refusing}\nesac\ncase \"$1\" in\n \
1515 image) printf 'Error: no such image\\n' >&2; exit 125 ;;\n start) : > '{up}' ;;\nesac\ncase \
1516 \"$1 $2\" in\n 'container inspect') case \"$4\" in\n *Labels*) printf '{digest}\\n';;\n \
1517 *) if [ -f '{up}' ]; then printf 'running\\n'; else printf 'exited\\n'; fi;;\n esac;;\nesac\nexit 0\n",
1518 calls = calls.display(),
1519 up = up.display(),
1520 );
1521 std::fs::write(&binary, script).expect("the stand-in engine");
1522 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
1523 (Engine::new(kind, &binary), calls)
1524 }
1525
1526 /// Docker names no one: its daemon runs the container as the ids it is handed, and the image
1527 /// knows nobody but the uid it was built with. So a person whose uid is another one is given
1528 /// an entry of their own, once, as root, right after the container comes up. The home, the
1529 /// files and the mounts are already right; what had nothing to read was the name that
1530 /// `whoami`, `git commit` and Node's `os.userInfo()` look up.
1531 #[test]
1532 fn a_person_the_image_does_not_know_is_named_inside_the_container_on_docker() {
1533 let folder = scratch("named-docker");
1534 let plan = profile_plan(&folder);
1535 let someone = HostUser::Ids { uid: 1234, gid: 1234 };
1536 let (engine, calls) = stopped_engine(EngineKind::Docker, &folder, &plan, someone, "");
1537 ensure_running(&engine, &plan, someone).expect("the container comes up");
1538 let asked = recorded(&calls);
1539 let started = asked.iter().position(|call| call == &format!("start {}", plan.name)).expect("it is started");
1540 // Every word of it: as root, in the container, with the ids as arguments of the shell
1541 // rather than written into it.
1542 let naming = naming_call(&plan.name, 1234, 1234);
1543 let named = asked.iter().position(|call| call == &naming).unwrap_or_else(|| panic!("named: {asked:#?}"));
1544 assert!(started < named, "named once the container is up, not before: {asked:#?}");
1545 // And once per container: a container that is already up is not written into again, so
1546 // every later tab of it costs no further command.
1547 ensure_running(&engine, &plan, someone).expect("the running container is left as it is");
1548 let again = recorded(&calls);
1549 assert_eq!(again.iter().filter(|call| call.as_str() == naming).count(), 1, "{again:#?}");
1550 let _ = std::fs::remove_dir_all(&folder);
1551 }
1552
1553 /// Podman writes that entry itself: `--userns=keep-id` maps the person into the container's
1554 /// user namespace, and podman puts the matching line in the password file as it starts the
1555 /// container. So there is nothing for QCode to do there, and it asks no root command of an
1556 /// engine that would only be second at its own work.
1557 #[test]
1558 fn podman_names_the_person_itself_and_qcode_runs_nothing() {
1559 let folder = scratch("named-podman");
1560 let plan = profile_plan(&folder);
1561 let someone = HostUser::Ids { uid: 1234, gid: 1234 };
1562 let (engine, calls) = stopped_engine(EngineKind::Podman, &folder, &plan, someone, "");
1563 ensure_running(&engine, &plan, someone).expect("the container comes up");
1564 let asked = recorded(&calls);
1565 assert!(asked.iter().any(|call| call == &format!("start {}", plan.name)), "{asked:#?}");
1566 assert!(!asked.iter().any(|call| call.contains("--user 0")), "no root command: {asked:#?}");
1567 assert!(!asked.iter().any(|call| call.contains(NAME_THE_USER)), "no script: {asked:#?}");
1568 let _ = std::fs::remove_dir_all(&folder);
1569 }
1570
1571 /// A host with no POSIX ids of its own — Windows, where the engine's own machine owns that
1572 /// side of a mount — has no uid to name, so there is nothing to write and nothing to ask.
1573 #[test]
1574 fn a_host_with_no_ids_of_its_own_is_named_nowhere() {
1575 let folder = scratch("named-none");
1576 let plan = profile_plan(&folder);
1577 let nobody = HostUser::ImageDefault;
1578 let (engine, calls) = stopped_engine(EngineKind::Docker, &folder, &plan, nobody, "");
1579 ensure_running(&engine, &plan, nobody).expect("the container comes up");
1580 let asked = recorded(&calls);
1581 assert!(asked.iter().any(|call| call == &format!("start {}", plan.name)), "{asked:#?}");
1582 assert!(!asked.iter().any(|call| call.contains(NAME_THE_USER)), "{asked:#?}");
1583 let _ = std::fs::remove_dir_all(&folder);
1584 }
1585
1586 /// An engine that will not write the entry is not a reason to fail a tab. The person comes up
1587 /// as a number, which is what they came up as before, and a tab that does not open at all is
1588 /// worse than a tab whose `whoami` cannot answer. Nothing is said on screen either: there is
1589 /// nothing the person could do about it, and the common case of a uid the image knows shows
1590 /// them no sign of it at all.
1591 #[test]
1592 fn an_engine_that_will_not_name_the_person_still_brings_the_tab_up() {
1593 let folder = scratch("named-refused");
1594 let plan = profile_plan(&folder);
1595 let someone = HostUser::Ids { uid: 1234, gid: 1234 };
1596 let (engine, calls) = stopped_engine(EngineKind::Docker, &folder, &plan, someone, "exec");
1597 ensure_running(&engine, &plan, someone).expect("the container comes up");
1598 assert!(recorded(&calls).iter().any(|call| call.starts_with("exec --user 0")), "it was asked: {calls:?}");
1599 let _ = std::fs::remove_dir_all(&folder);
1600 }
1601}