Skip to main content

qcode/profile/
identity.rs

1//! A profile's stored login, and the copy that carries it into a workspace.
2//!
3//! A login is made once, in the profile's own credentials volume. Every workspace that uses the
4//! profile is given a copy in its own home volume, and from then on the copy lives its own life:
5//! a harness that rewrites its login in one workspace touches no other. The copy is made when a
6//! workspace's container for the profile is first made, and again whenever the person asks to
7//! refresh the identity from the profile.
8//!
9//! The copying is the engine's work. A volume has no door of its own, so a container that mounts
10//! both volumes is made, told to copy, and removed again. What is spelled out here are its
11//! commands, so that a machine with no engine can read them back in tests.
12
13use crate::base::paths::{HOME_DIR, KEEP_ALIVE};
14use crate::desktop::login::{self, Fill};
15use crate::engine::names;
16use crate::engine::run::{EngineError, capture};
17use crate::engine::{
18    Access, ContainerCreate, ContainerState, Engine, EngineCommand, Exec, HostUser, Mount, MountSource, Network,
19};
20use crate::profile::{SafeName, account};
21use crate::store::WorkspaceId;
22
23/// Where a profile's credentials volume is mounted in every container that reads or writes it:
24/// the one a login is put into, and the one a copy is taken from.
25pub const STORE_DIR: &str = "/qcode-credentials";
26
27/// The volume to remove when the person signs a profile out. Workspaces keep the copies they were
28/// given; signing out takes away what new workspaces would have been given.
29#[must_use]
30pub fn sign_out(profile: &SafeName) -> String {
31    names::credential_volume(profile.as_str())
32}
33
34/// Whether `listing`, as `volume ls` prints it, names the volume that holds `profile`'s login.
35///
36/// The volume only ever comes into being when a login was captured into it, so its presence is
37/// the answer to "is this profile signed in?".
38#[must_use]
39pub fn is_stored(listing: &str, profile: &SafeName) -> bool {
40    listed(listing, &names::credential_volume(profile.as_str()))
41}
42
43/// Whether `listing` names `volume`.
44fn listed(listing: &str, volume: &str) -> bool {
45    listing.lines().any(|line| line.trim() == volume)
46}
47
48/// One workspace's copy of one profile's home: the volume the profile's login is written into,
49/// and the container that lives on it.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Home {
52    profile: SafeName,
53    workspace: WorkspaceId,
54}
55
56impl Home {
57    /// The home `workspace` keeps for `profile`.
58    #[must_use]
59    pub fn new(profile: SafeName, workspace: WorkspaceId) -> Self {
60        Self { profile, workspace }
61    }
62
63    /// The profile whose login the home holds a copy of.
64    #[must_use]
65    pub fn profile(&self) -> &SafeName {
66        &self.profile
67    }
68
69    /// The workspace the home belongs to.
70    #[must_use]
71    pub fn workspace(&self) -> &WorkspaceId {
72        &self.workspace
73    }
74
75    /// The volume itself, mounted at [`HOME_DIR`] in the workspace's container for the profile.
76    #[must_use]
77    pub fn volume(&self) -> String {
78        names::home_volume(self.workspace.as_str(), self.profile.as_str())
79    }
80
81    /// The container the harness runs in, which is the one that must not be running while its
82    /// login is rewritten under it.
83    #[must_use]
84    pub fn container(&self) -> String {
85        names::profile_container(self.workspace.as_str(), self.profile.as_str())
86    }
87
88    /// The short-lived container that writes the bridge's server into this home's settings, for a
89    /// profile whose own container cannot be written to from the inside.
90    #[must_use]
91    pub fn settings_container(&self) -> String {
92        names::settings_container(self.workspace.as_str(), self.profile.as_str())
93    }
94
95    /// The short-lived container that carries the login from one volume to the other.
96    fn courier(&self) -> String {
97        format!("qcode-refresh-{}-{}", self.workspace, self.profile)
98    }
99}
100
101/// The commands that write a profile's stored login into one workspace's home volume, in the
102/// order they run.
103///
104/// The courier container mounts the credentials volume read-only and the home volume writable,
105/// reaches no network, and runs as the same user the harness container runs as, so the copies
106/// belong to whoever will read them there. The copy is the whole content of the store: the
107/// store holds nothing but the login, put there by the capture that made it. The keys of a login
108/// that live in a harness's shared settings file are then merged into the home's file rather
109/// than copied over it ([`crate::profile::account`]). A window's login is
110/// two rows of the application's database rather than files, and is put into the home's database
111/// instead of copied over it (see [`crate::desktop::login`]).
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Rewrite {
114    /// Makes the courier.
115    pub create: EngineCommand,
116    /// Starts it, because a copy is an `exec` and an `exec` needs a running container.
117    pub start: EngineCommand,
118    /// Copies the store into the home.
119    pub copy: EngineCommand,
120    /// Merges the keys of the login a harness keeps among its other settings into the home's
121    /// files, which the copy cannot do without losing the rest of them (see
122    /// [`crate::profile::account`]).
123    pub merge: EngineCommand,
124    /// Removes the courier, whether or not the copy succeeded.
125    pub remove: EngineCommand,
126}
127
128/// Spells out the commands that give `home` its profile's stored login, `fill` saying whether a
129/// window's login may replace one the home already has (a harness's files are copied over either
130/// way; see [`login::give`]).
131#[must_use]
132pub fn rewrite(engine: &Engine, home: &Home, user: HostUser, fill: Fill) -> Rewrite {
133    let courier = home.courier();
134    let store = names::credential_volume(home.profile.as_str());
135    let volume = home.volume();
136    let mounts = [
137        Mount { source: MountSource::Volume(&store), target: STORE_DIR.as_ref(), access: Access::ReadOnly },
138        Mount { source: MountSource::Volume(&volume), target: HOME_DIR.as_ref(), access: Access::ReadWrite },
139    ];
140    let create = engine.create_container(&ContainerCreate {
141        name: &courier,
142        hostname: names::HOSTNAME,
143        labels: &[],
144        image: &names::profile_image(home.profile.as_str()),
145        mounts: &mounts,
146        network: Network::None,
147        user,
148        workdir: None,
149        command: KEEP_ALIVE,
150    });
151    let give = login::give(fill);
152    let words: Vec<&str> = give.iter().map(String::as_str).collect();
153    let copy = engine.exec_without_terminal(&Exec { container: &courier, command: &words });
154    let merge = account::merge();
155    let words: Vec<&str> = merge.iter().map(String::as_str).collect();
156    let merge = engine.exec_without_terminal(&Exec { container: &courier, command: &words });
157    Rewrite { create, start: engine.start_container(&courier), copy, merge, remove: engine.remove_container(&courier) }
158}
159
160/// Runs a [`Rewrite`] to the end, taking the courier away again whatever happened.
161///
162/// A courier left behind by a copy that was interrupted is removed first: it would otherwise
163/// keep the next copy from being made under the same name.
164fn run(rewrite: &Rewrite) -> Result<(), EngineError> {
165    let _ = capture(&rewrite.remove);
166    let copied = capture(&rewrite.create)
167        .and_then(|_| capture(&rewrite.start))
168        .and_then(|_| capture(&rewrite.copy))
169        .and_then(|_| capture(&rewrite.merge))
170        .map(|_| ());
171    let _ = capture(&rewrite.remove);
172    copied
173}
174
175/// Whether `home`'s volume exists yet. The container's creation makes it, so this is asked
176/// before that, and the answer is what decides whether the login is copied in afterwards.
177///
178/// # Errors
179///
180/// When the engine cannot list its volumes.
181pub fn home_exists(engine: &Engine, home: &Home) -> Result<bool, EngineError> {
182    Ok(listed(&capture(&engine.list_volumes())?, &home.volume()))
183}
184
185/// Gives a home its profile's stored login when it has none of its own.
186///
187/// A terminal harness's home is given it once, when the home was just made. A window's home is
188/// given it every time the window opens, because a window's login lives in a database the home
189/// has whether or not anyone signed in there: the copy looks for a login in it and leaves one it
190/// finds alone ([`Fill::Keep`]), so a workspace that was signed in to by hand keeps what it has.
191///
192/// A profile with no stored login gives nothing, and that is not a failure: the harness asks
193/// for its login the first time it runs, as it would on any machine. Nothing is made for it
194/// here, because a credentials volume that comes into being empty would read as a login from
195/// then on.
196///
197/// # Errors
198///
199/// When the engine cannot list its volumes or refuses the copy.
200pub fn first_fill(engine: &Engine, home: &Home, user: HostUser) -> Result<(), EngineError> {
201    if !is_stored(&capture(&engine.list_volumes())?, &home.profile) {
202        return Ok(());
203    }
204    run(&rewrite(engine, home, user, Fill::Keep))
205}
206
207/// What refreshing a profile's login across its workspaces came to.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct Refreshed {
210    /// The workspaces whose copy was rewritten.
211    pub written: Vec<WorkspaceId>,
212    /// The workspaces that were left alone because their container for the profile is running.
213    pub running: Vec<WorkspaceId>,
214}
215
216/// Why a refresh did not happen at all.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum RefreshError {
219    /// No login is stored for the profile, so there is nothing to give.
220    NotStored,
221    /// The engine would not do it. The text is its own words, or the command it stopped at when
222    /// it said nothing.
223    Engine(String),
224}
225
226impl From<EngineError> for RefreshError {
227    fn from(error: EngineError) -> Self {
228        Self::Engine(match error {
229            EngineError::NotRunnable { error, .. } => error.to_string(),
230            EngineError::Failed(failure) => failure.output,
231            EngineError::Cancelled { command } => {
232                format!("{} {}", command.program.display(), command.args.join(" ".as_ref()).to_string_lossy())
233            }
234            EngineError::TimedOut { command, after } => crate::engine::run::timed_out(&command, after),
235        })
236    }
237}
238
239/// Writes `profile`'s stored login into the home of every workspace in `workspaces` that is not
240/// running the profile right now, and says which were written and which were left.
241///
242/// A workspace whose container for the profile is running is skipped rather than stopped. A
243/// running container is a harness someone may be in the middle of using; stopping it would end
244/// that work unasked, and rewriting the login under it would leave the harness holding one it
245/// no longer has. Skipping loses nothing: the person stops the container from the workspace
246/// screen and refreshes again, and the copy that was there stays whole in the meantime.
247///
248/// The first workspace the engine refuses ends the round; what was written before it stays
249/// written.
250///
251/// # Errors
252///
253/// [`RefreshError::NotStored`] when the profile has no login, before anything is touched;
254/// [`RefreshError::Engine`] when the engine cannot be asked or refuses a copy.
255pub fn refresh_all(
256    engine: &Engine,
257    profile: &SafeName,
258    workspaces: &[WorkspaceId],
259    user: HostUser,
260) -> Result<Refreshed, RefreshError> {
261    if !is_stored(&capture(&engine.list_volumes())?, profile) {
262        return Err(RefreshError::NotStored);
263    }
264    let mut done = Refreshed { written: Vec::new(), running: Vec::new() };
265    for workspace in workspaces {
266        let home = Home::new(profile.clone(), workspace.clone());
267        // A container the engine cannot answer for is one that is not there, and one that is
268        // not there is not running.
269        let running = capture(&engine.container_state(&home.container()))
270            .is_ok_and(|word| ContainerState::parse(&word).is_running());
271        if running {
272            done.running.push(workspace.clone());
273            continue;
274        }
275        // Asked for by the person, so a window's copy replaces what the home had, as files do.
276        run(&rewrite(engine, &home, user, Fill::Replace))?;
277        done.written.push(workspace.clone());
278    }
279    Ok(done)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::engine::EngineKind;
286
287    fn args(command: &EngineCommand) -> Vec<String> {
288        command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
289    }
290
291    fn home() -> Home {
292        Home::new(
293            SafeName::parse("claude-sub").expect("the name is safe"),
294            WorkspaceId::parse("my-app").expect("the id is legal"),
295        )
296    }
297
298    #[test]
299    fn a_home_is_named_after_its_workspace_and_its_profile() {
300        let home = home();
301        assert_eq!(home.volume(), "qcode-home-my-app-claude-sub");
302        assert_eq!(home.container(), "qcode-my-app-claude-sub");
303        assert_eq!(home.courier(), "qcode-refresh-my-app-claude-sub");
304        assert_eq!(home.profile().as_str(), "claude-sub");
305        assert_eq!(home.workspace().as_str(), "my-app");
306    }
307
308    #[test]
309    fn signing_out_names_the_volume_that_holds_the_login() {
310        let profile = SafeName::from_display("claude-sub").expect("has letters");
311        assert_eq!(sign_out(&profile), names::credential_volume(profile.as_str()));
312    }
313
314    #[test]
315    fn a_login_is_stored_when_the_listing_names_its_volume() {
316        let profile = SafeName::parse("claude-sub").expect("the name is safe");
317        assert!(is_stored("qcode-cred-codex\nqcode-cred-claude-sub\n", &profile));
318        assert!(!is_stored("qcode-cred-claude-sub-old\nqcode-home-p-claude-sub\n", &profile));
319        assert!(!is_stored("", &profile));
320    }
321
322    #[test]
323    fn the_courier_mounts_the_store_read_only_and_the_home_writable_off_the_network() {
324        let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
325        let rewrite = rewrite(&engine, &home(), HostUser::Ids { uid: 1000, gid: 1000 }, Fill::Keep);
326        assert_eq!(
327            args(&rewrite.create),
328            [
329                "create",
330                "--name",
331                "qcode-refresh-my-app-claude-sub",
332                "--hostname",
333                names::HOSTNAME,
334                "--userns=keep-id",
335                "--network=none",
336                "--volume",
337                "qcode-cred-claude-sub:/qcode-credentials:ro,z",
338                "--volume",
339                "qcode-home-my-app-claude-sub:/home/qcode:rw,z",
340                "--pull=never",
341                "qcode/profile/claude-sub",
342                KEEP_ALIVE[0],
343                KEEP_ALIVE[1],
344                KEEP_ALIVE[2],
345            ]
346        );
347        assert_eq!(args(&rewrite.start), ["start", "qcode-refresh-my-app-claude-sub"]);
348        let copy = args(&rewrite.copy);
349        assert_eq!(&copy[..4], ["exec", "qcode-refresh-my-app-claude-sub", "sh", "-c"]);
350        // A harness's files are copied over whole; a window's rows are put into its database.
351        assert!(copy[4].ends_with("else cp -R /qcode-credentials/. /home/qcode/; fi"), "{copy:?}");
352        assert_eq!(copy.last().map(String::as_str), Some("keep"), "a first fill never replaces a login");
353        let merge = args(&rewrite.merge);
354        assert_eq!(&merge[..3], ["exec", "qcode-refresh-my-app-claude-sub", "node"]);
355        assert_eq!(&merge[merge.len() - 3..], [STORE_DIR, HOME_DIR, account::MERGE_DIR], "{merge:?}");
356        assert_eq!(args(&rewrite.remove), ["rm", "--force", "qcode-refresh-my-app-claude-sub"]);
357    }
358
359    #[test]
360    fn docker_spells_the_same_courier_with_its_own_user_flag() {
361        let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
362        let rewrite = rewrite(&engine, &home(), HostUser::Ids { uid: 1000, gid: 100 }, Fill::Replace);
363        let create = args(&rewrite.create);
364        assert_eq!(
365            &create[..7],
366            [
367                "create",
368                "--name",
369                "qcode-refresh-my-app-claude-sub",
370                "--hostname",
371                names::HOSTNAME,
372                "--user",
373                "1000:100"
374            ]
375        );
376        assert!(create.contains(&"qcode-cred-claude-sub:/qcode-credentials:ro".to_owned()), "{create:?}");
377        assert!(create.contains(&"qcode-home-my-app-claude-sub:/home/qcode:rw".to_owned()), "{create:?}");
378        assert_eq!(rewrite.copy.program, std::path::Path::new("/usr/bin/docker"));
379    }
380
381    #[test]
382    fn the_copy_runs_without_a_terminal_so_docker_takes_it_from_a_pipe() {
383        let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
384        let rewrite = rewrite(&engine, &home(), HostUser::ImageDefault, Fill::Keep);
385        let copy = args(&rewrite.copy);
386        assert!(!copy.contains(&"--tty".to_owned()), "{copy:?}");
387        assert!(!copy.contains(&"--interactive".to_owned()), "{copy:?}");
388    }
389
390    #[test]
391    fn an_engine_that_is_not_there_answers_with_the_machines_words() {
392        // A binary that does not exist: the round stops at the volume listing, before any
393        // workspace is touched, and the words are the operating system's.
394        let engine = Engine::new(EngineKind::Podman, "/qcode/no/such/engine");
395        let profile = SafeName::parse("claude-sub").expect("the name is safe");
396        let outcome =
397            refresh_all(&engine, &profile, &[WorkspaceId::parse("p").expect("legal")], HostUser::ImageDefault);
398        match outcome {
399            Err(RefreshError::Engine(words)) => assert!(!words.is_empty()),
400            other => panic!("expected the engine's failure, got {other:?}"),
401        }
402        assert!(matches!(first_fill(&engine, &home(), HostUser::ImageDefault), Err(EngineError::NotRunnable { .. })));
403        assert!(matches!(home_exists(&engine, &home()), Err(EngineError::NotRunnable { .. })));
404    }
405}