Skip to main content

qcode/profile/
account.rs

1//! The part of a login that lives inside a harness's shared settings file rather than in a file
2//! of its own.
3//!
4//! Claude Code keeps its tokens in `~/.claude/.credentials.json`, but the other half of a
5//! sign-in in `~/.claude.json`, beside the folders it trusts and the servers it starts: the
6//! account (`oauthAccount`) and the end of its first-start questions (`hasCompletedOnboarding`),
7//! which a sign-in is one of. Read in its 2.1.282 bundle: the first start is shown whenever that
8//! flag is missing, and it always holds the step that signs in, whatever tokens are on disk. A
9//! home given the tokens alone was therefore asked to sign in again on a profile whose image
10//! writes no `~/.claude.json`, and everywhere else showed no account.
11//!
12//! The file cannot be copied whole: in a workspace it holds what the profile's template wrote
13//! and what QCode's bridge registered, and the login container's copy holds neither. So the keys
14//! of the login are taken out on their own, into [`MERGE_DIR`] of the profile's credentials
15//! volume, and merged into the home's file when a home is given the login: every key the home
16//! has stays, the login's keys are the profile's.
17
18use crate::base::paths::HOME_DIR;
19use crate::profile::HarnessKind;
20use crate::profile::identity::STORE_DIR;
21
22/// The folder of a credentials volume, and of a capture, that holds the keys to be merged into a
23/// home's files rather than copied over them: the same path under it as the file under the home.
24pub const MERGE_DIR: &str = ".qcode-merge";
25
26/// A settings file of a harness that a login writes some of its keys into.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Shared {
29    /// Where the file is, relative to the harness's home directory.
30    pub path: &'static str,
31    /// The keys that are the login's.
32    pub keys: &'static [&'static str],
33}
34
35/// Claude Code's: the account, and the flag with the version that closed the first start.
36const CLAUDE_CODE: Shared =
37    Shared { path: ".claude.json", keys: &["oauthAccount", "hasCompletedOnboarding", "lastOnboardingVersion"] };
38
39/// The file `harness`'s login shares with its other settings, if it has one.
40#[must_use]
41pub fn shared(harness: HarnessKind) -> Option<Shared> {
42    match harness {
43        HarnessKind::ClaudeCode => Some(CLAUDE_CODE),
44        _ => None,
45    }
46}
47
48/// Takes the named keys out of a JSON file into a file of their own. Nothing is written when the
49/// file is not there, cannot be read, or holds none of them: a login that wrote none leaves
50/// nothing to merge, and that is not a failure.
51const TAKE: &str = "const fs = require('fs'), path = require('path');
52const [from, to, ...keys] = process.argv.slice(1);
53let all; try { all = JSON.parse(fs.readFileSync(from, 'utf8')); } catch (e) { process.exit(0); }
54const taken = {};
55for (const key of keys) if (all && all[key] !== undefined) taken[key] = all[key];
56if (Object.keys(taken).length === 0) process.exit(0);
57fs.mkdirSync(path.dirname(to), { recursive: true });
58fs.writeFileSync(to, JSON.stringify(taken), { mode: 0o600 });";
59
60/// Merges every file under a store's merge folder into the file at the same path of a home,
61/// keeping every key the home's file has and taking the store's for the rest, then removes the
62/// merge folder the copy of the store brought into the home. A home file that is there and is
63/// not JSON is left as it is rather than replaced by the login's keys alone.
64const MERGE: &str = "const fs = require('fs'), path = require('path');
65const [store, home, folder] = process.argv.slice(1);
66const root = path.join(store, folder);
67const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true })
68  .flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]);
69let files = []; try { files = walk(root); } catch (e) {}
70for (const file of files) {
71  const target = path.join(home, path.relative(root, file));
72  let into = {};
73  if (fs.existsSync(target)) { try { into = JSON.parse(fs.readFileSync(target, 'utf8')); } catch (e) { continue; } }
74  const given = JSON.parse(fs.readFileSync(file, 'utf8'));
75  fs.mkdirSync(path.dirname(target), { recursive: true });
76  fs.writeFileSync(target, JSON.stringify({ ...into, ...given }, null, 2), { mode: 0o600 });
77}
78fs.rmSync(path.join(home, folder), { recursive: true, force: true });";
79
80/// The command, run in the container a login was made in, that takes the login's keys of
81/// `harness`'s shared file out into the capture folder `capture`; `None` for a harness whose
82/// login is only files of its own.
83#[must_use]
84pub fn take(harness: HarnessKind, capture: &str) -> Option<Vec<String>> {
85    let shared = shared(harness)?;
86    let mut words = vec![
87        "node".to_owned(),
88        "-e".to_owned(),
89        TAKE.to_owned(),
90        format!("{HOME_DIR}/{}", shared.path),
91        format!("{capture}/{MERGE_DIR}/{}", shared.path),
92    ];
93    words.extend(shared.keys.iter().map(|key| (*key).to_owned()));
94    Some(words)
95}
96
97/// Takes the named keys out of a JSON file where it is, keeping every other key. A file that is
98/// not there, or not JSON, is left as it is.
99const STRIP: &str = "const fs = require('fs');
100const [file, ...keys] = process.argv.slice(1);
101let all; try { all = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { process.exit(0); }
102if (!all || typeof all !== 'object') process.exit(0);
103for (const key of keys) delete all[key];
104fs.writeFileSync(file, JSON.stringify(all, null, 2));";
105
106/// The command, run in a profile's shell before what it holds becomes the profile's image, that
107/// takes the login's keys out of `harness`'s shared file: a login never goes into an image, where
108/// every workspace of the profile would carry it; `None` for a harness whose login is only files
109/// of its own.
110#[must_use]
111pub fn strip(harness: HarnessKind) -> Option<Vec<String>> {
112    let shared = shared(harness)?;
113    let mut words = vec!["node".to_owned(), "-e".to_owned(), STRIP.to_owned(), format!("{HOME_DIR}/{}", shared.path)];
114    words.extend(shared.keys.iter().map(|key| (*key).to_owned()));
115    Some(words)
116}
117
118/// The command, run in the container that gives a home its profile's login after the store was
119/// copied in, that merges the login's keys into the home's shared files.
120#[must_use]
121pub fn merge() -> Vec<String> {
122    ["node", "-e", MERGE, STORE_DIR, HOME_DIR, MERGE_DIR].map(str::to_owned).to_vec()
123}
124
125#[cfg(all(test, unix))]
126mod tests {
127    use std::path::Path;
128    use std::process::Command;
129
130    use super::*;
131
132    /// Runs `words` with this machine's Node, with the container's folders in them moved under
133    /// `root`, and answers whether it succeeded.
134    fn node(words: &[String], root: &Path) -> bool {
135        let moved: Vec<String> = words
136            .iter()
137            .map(|word| if word.starts_with('/') { format!("{}{word}", root.display()) } else { word.clone() })
138            .collect();
139        Command::new(&moved[0]).args(&moved[1..]).status().is_ok_and(|status| status.success())
140    }
141
142    fn read(path: &Path) -> serde_json::Value {
143        serde_json::from_str(&std::fs::read_to_string(path).expect("the file is there")).expect("JSON")
144    }
145
146    #[test]
147    fn only_claude_code_shares_its_login_with_its_settings() {
148        assert_eq!(shared(HarnessKind::ClaudeCode).map(|shared| shared.path), Some(".claude.json"));
149        for harness in HarnessKind::ALL.into_iter().filter(|harness| *harness != HarnessKind::ClaudeCode) {
150            assert_eq!(shared(harness), None, "{harness:?}");
151            assert_eq!(take(harness, "/capture"), None, "{harness:?}");
152        }
153    }
154
155    #[test]
156    fn a_login_s_keys_are_taken_out_and_merged_into_a_home_keeping_what_the_home_had() {
157        let root = crate::testing::scratch("account-merge");
158        let _ = std::fs::remove_dir_all(&root);
159        let home = root.join(HOME_DIR.trim_start_matches('/'));
160        let capture = "/capture";
161        std::fs::create_dir_all(&home).expect("a home");
162        // The login container's file, as Claude Code leaves it after a sign-in: the login's keys
163        // among others that belong to that container alone.
164        std::fs::write(
165            home.join(".claude.json"),
166            r#"{"hasCompletedOnboarding":true,"oauthAccount":{"emailAddress":"take@qcode.test"},"projects":{"/tmp":{}},"numStartups":3}"#,
167        )
168        .expect("the login container's file");
169        let words = take(HarnessKind::ClaudeCode, capture).expect("Claude Code has keys to take");
170        assert!(node(&words, &root), "the keys are taken");
171        let taken = read(&root.join("capture").join(MERGE_DIR).join(".claude.json"));
172        assert_eq!(taken["oauthAccount"]["emailAddress"], "take@qcode.test");
173        assert_eq!(taken["hasCompletedOnboarding"], true);
174        assert!(taken.get("projects").is_none() && taken.get("numStartups").is_none(), "{taken}");
175
176        // The store holds what was taken; a workspace's home holds what its template wrote and
177        // what the copy of the store brought in beside it.
178        let store = root.join(STORE_DIR.trim_start_matches('/'));
179        std::fs::create_dir_all(store.join(MERGE_DIR)).expect("a store");
180        std::fs::rename(
181            root.join("capture").join(MERGE_DIR).join(".claude.json"),
182            store.join(MERGE_DIR).join(".claude.json"),
183        )
184        .expect("the capture is stored");
185        std::fs::write(home.join(".claude.json"), r#"{"projects":{"/work":{"hasTrustDialogAccepted":true}}}"#)
186            .expect("the template's file");
187        std::fs::create_dir_all(home.join(MERGE_DIR)).expect("the copy's folder");
188        std::fs::copy(store.join(MERGE_DIR).join(".claude.json"), home.join(MERGE_DIR).join(".claude.json"))
189            .expect("the copy of the store");
190        assert!(node(&merge(), &root), "the keys are merged");
191        let merged = read(&home.join(".claude.json"));
192        assert_eq!(merged["oauthAccount"]["emailAddress"], "take@qcode.test");
193        assert_eq!(merged["hasCompletedOnboarding"], true);
194        assert_eq!(merged["projects"]["/work"]["hasTrustDialogAccepted"], true, "the template's keys stay");
195        assert!(!home.join(MERGE_DIR).exists(), "nothing of the copy is left in the home");
196        let _ = std::fs::remove_dir_all(&root);
197    }
198
199    #[test]
200    fn a_home_with_no_file_gets_one_and_a_home_file_that_is_not_json_is_left_alone() {
201        let root = crate::testing::scratch("account-merge-edges");
202        let _ = std::fs::remove_dir_all(&root);
203        let home = root.join(HOME_DIR.trim_start_matches('/'));
204        let store = root.join(STORE_DIR.trim_start_matches('/'));
205        std::fs::create_dir_all(store.join(MERGE_DIR)).expect("a store");
206        std::fs::create_dir_all(&home).expect("a home");
207        std::fs::write(store.join(MERGE_DIR).join(".claude.json"), r#"{"hasCompletedOnboarding":true}"#).expect("keys");
208        assert!(node(&merge(), &root));
209        assert_eq!(read(&home.join(".claude.json"))["hasCompletedOnboarding"], true);
210
211        std::fs::write(home.join(".claude.json"), "not json").expect("a broken file");
212        assert!(node(&merge(), &root));
213        assert_eq!(std::fs::read_to_string(home.join(".claude.json")).expect("there"), "not json");
214
215        // A login container whose harness wrote no such file leaves nothing to merge.
216        std::fs::remove_file(home.join(".claude.json")).expect("removed");
217        let words = take(HarnessKind::ClaudeCode, "/capture").expect("keys");
218        assert!(node(&words, &root));
219        assert!(!root.join("capture").exists(), "nothing was taken");
220        let _ = std::fs::remove_dir_all(&root);
221    }
222    #[test]
223    fn a_login_made_in_a_profiles_shell_is_taken_out_of_the_shared_file_and_the_rest_stays() {
224        let root = crate::testing::scratch("account-strip");
225        let _ = std::fs::remove_dir_all(&root);
226        let home = root.join(HOME_DIR.trim_start_matches('/'));
227        std::fs::create_dir_all(&home).expect("a home");
228        std::fs::write(
229            home.join(".claude.json"),
230            r#"{"hasCompletedOnboarding":true,"lastOnboardingVersion":"2.1.282","oauthAccount":{"emailAddress":"strip@qcode.test"},"theme":"dark"}"#,
231        )
232        .expect("the shell's file");
233        let words = strip(HarnessKind::ClaudeCode).expect("Claude Code has keys to strip");
234        assert!(node(&words, &root), "the keys are stripped");
235        let left = read(&home.join(".claude.json"));
236        assert_eq!(left, serde_json::json!({"theme": "dark"}));
237        std::fs::write(home.join(".claude.json"), "not json").expect("a broken file");
238        assert!(node(&words, &root));
239        assert_eq!(std::fs::read_to_string(home.join(".claude.json")).expect("there"), "not json");
240        assert_eq!(strip(HarnessKind::OpenCode), None);
241        let _ = std::fs::remove_dir_all(&root);
242    }
243}