Give Claude Code a writable conversation folder under Flatpak, and say how to sign in there (#561)

Fixes #506. Under a Flatpak, where ~/.claude is read-only, Claude Code is started with a writable CLAUDE_CONFIG_DIR in the app's data folder so its conversations are saved and --resume works; the sign-in message says how to sign in inside the sandbox. A CLAUDE_CONFIG_DIR the person sets wins, and SSH builds are unchanged.
This commit is contained in:
Fábio Soares
2026-11-03 08:01:26 +00:00
committed by GitHub
parent d3bbf99b4a
commit 199a71e08f
3 changed files with 86 additions and 8 deletions
+23 -2
View File
@@ -380,10 +380,31 @@ impl AgentAdapter for ClaudeCode {
}
/// Claude Code's own folder: `CLAUDE_CONFIG_DIR` when it's set (the person's choice, which the
/// agent we start keeps too), else `~/.claude`.
/// agent we start keeps too), else our own under a Flatpak (see [`flatpak_claude_dir`]), else
/// `~/.claude`.
fn claude_config_dir() -> Option<std::path::PathBuf> {
let set = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
set.or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
set.or_else(flatpak_claude_dir).or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
}
/// Whether this app runs in a Flatpak sandbox (it sets `FLATPAK_ID` and makes `/.flatpak-info`).
pub fn in_flatpak() -> bool {
static IN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*IN.get_or_init(|| std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists())
}
/// Where Claude Code keeps its conversations when we start it under a Flatpak and the person
/// hasn't chosen a folder: its own is `~/.claude`, which the sandbox only lets us read, so a
/// conversation was never saved and `--resume` found nothing (#506). A folder of ours in the
/// app's data folder is writable, and kept with the app. `None` outside a Flatpak, or when
/// `CLAUDE_CONFIG_DIR` is set.
pub fn flatpak_claude_dir() -> Option<std::path::PathBuf> {
let chosen = std::env::var_os("CLAUDE_CONFIG_DIR").is_some_and(|v| !v.is_empty());
flatpak_claude_dir_in(in_flatpak(), chosen, dirs::data_dir())
}
pub fn flatpak_claude_dir_in(flatpak: bool, chosen: bool, data: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
(flatpak && !chosen).then_some(data?.join("craft-anything").join("claude"))
}
/// Claude Code's name for the folder a session ran in: the path with everything but letters and
+46 -6
View File
@@ -185,13 +185,28 @@ pub fn verify_flags(program: &str, help_args: &[&str], needed: &[&str]) -> bool
/// The fix for a sign-in failure, in plain words. Sign-in always happens in Claude Code itself;
/// we never see or handle the account.
pub fn sign_in_hint(message: &str) -> Option<String> {
let m = message.to_ascii_lowercase();
["authenticat", "oauth", "log in", "login", "not logged", "api key", "unauthorized"]
.iter()
.any(|w| m.contains(w))
.then(|| "Sign in to Claude Code: open a terminal and run `claude auth login` (or start `claude` and type /login), then try again.".to_string())
sign_in_hint_in(message, adapters::in_flatpak())
}
/// [`sign_in_hint`] for a Flatpak or not. In a Flatpak, Claude Code keeps its sign-in and
/// conversations in a folder of the app's own (#506), so signing in from a normal terminal signs
/// in the other copy: it's done through the sandbox, with that folder.
fn sign_in_hint_in(message: &str, flatpak: bool) -> Option<String> {
let m = message.to_ascii_lowercase();
let wants_sign_in = ["authenticat", "oauth", "log in", "login", "not logged", "api key", "unauthorized"].iter().any(|w| m.contains(w));
if !wants_sign_in {
return None;
}
Some(if flatpak {
format!("Sign in to Claude Code inside this app's sandbox: open a terminal and run `{FLATPAK_SIGN_IN}`, then try again.")
} else {
"Sign in to Claude Code: open a terminal and run `claude auth login` (or start `claude` and type /login), then try again.".to_string()
})
}
/// Signing in for the Claude Code that a Flatpak build starts: the same folder it will use.
const FLATPAK_SIGN_IN: &str = "flatpak run --command=sh ai.storyteller.craft-anything -c 'CLAUDE_CONFIG_DIR=\"$XDG_DATA_HOME/craft-anything/claude\" \"$HOME/.local/bin/claude\" auth login'";
/// Words in an API's answer when the installed Claude Code is older than the model it was asked
/// to use (#165).
pub const TOO_OLD_FOR_MODEL: &str = "does not support this model";
@@ -561,7 +576,15 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender<AgentEvent>, stop: &Stop, sta
// raised to match, so nothing cuts a long clone short (its defaults stop a quiet tool call at
// 30 minutes and a background build at 10).
let (turn, quiet) = if builder { (24 * 3600, 8 * 3600) } else { (30 * 60, 15 * 60) };
let env = agent_env(builder, spec.options.effort);
let mut env = agent_env(builder, spec.options.effort);
// Under a Flatpak, Claude Code gets a folder it can write its conversations to (#506); on
// another computer over SSH it keeps its own.
if remote.is_none()
&& let Some(dir) = adapters::flatpak_claude_dir()
{
let _ = std::fs::create_dir_all(&dir);
env.push(("CLAUDE_CONFIG_DIR", dir.to_string_lossy().into_owned()));
}
let (prog, argv) = match &remote {
Some(host) => ("ssh".to_string(), crate::ssh::ssh_args(host, &remote_command(&program, args, &env))),
None => (program.clone(), args),
@@ -823,6 +846,23 @@ impl Drop for CliConversation {
mod tests {
use super::*;
/// #506: under a Flatpak `~/.claude` is read-only, so Claude Code gets a folder of ours to save
/// its conversations in (so `--resume` finds them); the person's own `CLAUDE_CONFIG_DIR` and a
/// normal install are left as they are, and the sign-in hint says how to sign in there.
#[test]
fn under_a_flatpak_claude_code_keeps_its_conversations_in_a_folder_it_can_write() {
let data = || Some(std::path::PathBuf::from("/home/u/.var/app/ai.storyteller.craft-anything/data"));
let dir = adapters::flatpak_claude_dir_in(true, false, data()).unwrap();
assert!(dir.ends_with("craft-anything/claude") && dir.starts_with("/home/u/.var/app/ai.storyteller.craft-anything/data"), "{dir:?}");
assert_eq!(adapters::flatpak_claude_dir_in(true, true, data()), None, "their own choice stays");
assert_eq!(adapters::flatpak_claude_dir_in(false, false, data()), None, "a normal install keeps ~/.claude");
assert_eq!(adapters::flatpak_claude_dir_in(true, false, None), None);
let hint = sign_in_hint_in("Not logged in", true).unwrap();
assert!(hint.contains("flatpak run") && hint.contains("CLAUDE_CONFIG_DIR") && hint.contains("auth login"), "{hint}");
assert!(sign_in_hint_in("Not logged in", false).unwrap().contains("`claude auth login`"));
assert_eq!(sign_in_hint_in("all fine", true), None);
}
/// Over SSH our environment doesn't reach the other computer, so the chosen effort (and Claude
/// Code's raised limits) go in the remote command itself.
#[test]
+17
View File
@@ -30,6 +30,23 @@ integration does, why it is shaped this way, and what remains to confirm.
- Before a build starts, `claude auth status` is checked. If Claude Code isn't signed in, the run
stops with: "Sign in to Claude Code: open a terminal and run `claude auth login`".
## Under Flatpak
The Flatpak build can read the home folder but not write to it, so Claude Code can't save its
conversations in `~/.claude`: the first turn ran, and `--resume` then said "No conversation found
with session ID" (#506). When the app runs in a Flatpak and `CLAUDE_CONFIG_DIR` isn't set, it starts
Claude Code with `CLAUDE_CONFIG_DIR` pointing at `craft-anything/claude` in the app's own data
folder (`~/.var/app/ai.storyteller.craft-anything/data/`), which is writable. Two consequences:
- Claude Code there is signed in separately from the one on the host. The sign-in message gives the
command that does it through the sandbox:
`flatpak run --command=sh ai.storyteller.craft-anything -c 'CLAUDE_CONFIG_DIR="$XDG_DATA_HOME/craft-anything/claude" "$HOME/.local/bin/claude" auth login'`
(Craft Anything never reads Claude Code's credentials, so they aren't copied over.)
- Setting `CLAUDE_CONFIG_DIR` yourself, for example with `flatpak override --user --env=…`, wins.
Builds on another computer over SSH are not changed.
Not yet checked in a real Flatpak (the tests cover the choice of folder and the message).
## Why this is within Anthropic's terms (as published, October 2026)
From Claude Code's *Legal and compliance* page: