Skip to content
Merged
223 changes: 223 additions & 0 deletions crates/fctl-agent/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,76 @@ pub const FORBIDDEN_ENV: &[&str] = &[
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
];

/// The oldest Claude Code fctl works with. 2.1.101 is the first that asks
/// its host for a fresh OAuth access token (the `oauth_token_refresh`
/// control request), which sessions on a full Claude login need once
/// theirs expires; Remote Control's `remote_control` request came in
/// 2.1.63.
pub const MIN_VERSION: Version = Version(2, 1, 101);

/// A Claude Code version: major, minor, patch.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version(pub u32, pub u32, pub u32);

impl Version {
/// The first `X.Y.Z` in `claude --version`'s output, `2.1.302 (Claude
/// Code)`; a pre-release suffix doesn't count.
pub fn parse(text: &str) -> Option<Self> {
text.split_whitespace().find_map(|word| {
let word = word.trim_start_matches('v');
let mut parts = word.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?;
let digits = patch.chars().take_while(char::is_ascii_digit).count();
let patch = patch[..digits].parse().ok()?;
Some(Self(major, minor, patch))
})
}
}

impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.0, self.1, self.2)
}
}

/// Asks `bin --version` which Claude Code the sandbox has. `Err` says why
/// sessions can't run with it: it is missing, or older than
/// [`MIN_VERSION`]. `Ok(None)` when it doesn't say.
pub async fn check_version(bin: &str) -> std::result::Result<Option<Version>, String> {
let run = Command::new(bin)
.arg("--version")
.stdin(Stdio::null())
.kill_on_drop(true)
.output();
let out = match tokio::time::timeout(Duration::from_secs(30), run).await {
Ok(Ok(out)) => out,
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!(
"This sandbox has no Claude Code (`{bin}` is not on the PATH): install it in the image, or build the image from ghcr.io/hyperstructure/sandbox, which has it"
));
}
Ok(Err(e)) => {
tracing::warn!("running {bin} --version: {e}");
return Ok(None);
}
Err(_) => {
tracing::warn!("{bin} --version did not answer within 30 seconds");
return Ok(None);
}
};
let Some(version) = Version::parse(&String::from_utf8_lossy(&out.stdout)) else {
return Ok(None);
};
if version < MIN_VERSION {
return Err(format!(
"This sandbox has Claude Code {version}, and fctl needs {MIN_VERSION} or later: update it in the image (ghcr.io/hyperstructure/sandbox has the newest)"
));
}
Ok(Some(version))
}

pub enum ClaudeOut {
/// A stream-json message.
Message(Value),
Expand Down Expand Up @@ -107,6 +177,52 @@ pub fn user_settings(project: &ProjectSettings, co_authors: &[GitIdentity]) -> V
settings
}

/// Claude's user settings: the image's own `~/.claude/settings.json`, with
/// fctl's ([`user_settings`]) over it. fctl decides the permission mode,
/// `attribution`, `disableClaudeAiConnectors` and the auto-updater, and
/// drops what could move Claude off the subscription or out of bypass mode:
/// `apiKeyHelper`, `ANTHROPIC_*` and provider variables in `env` (as
/// [`project_settings_are_unsafe`] finds them in a repository), and
/// `permissions.disableBypassPermissionsMode`. Everything else the image
/// ships stays: plugins and marketplaces, hooks, permission rules, other
/// variables. A sandbox that resumes merges into what it has.
pub fn merge_user_settings(image: &Value, ours: &Value) -> Value {
let mut merged = match image {
Value::Object(o) => o.clone(),
_ => serde_json::Map::new(),
};
for key in ["apiKeyHelper", "attribution", "disableClaudeAiConnectors"] {
merged.remove(key);
}
let mut env = take_object(&mut merged, "env");
env.retain(|k, _| !k.starts_with("ANTHROPIC_") && !FORBIDDEN_ENV.contains(&k.as_str()));
let mut permissions = take_object(&mut merged, "permissions");
permissions.remove("disableBypassPermissionsMode");
for (key, value) in ours.as_object().into_iter().flatten() {
match (key.as_str(), value) {
("env", Value::Object(o)) => env.extend(o.clone()),
("permissions", Value::Object(o)) => permissions.extend(o.clone()),
_ => {
merged.insert(key.clone(), value.clone());
}
}
}
merged.insert("env".into(), Value::Object(env));
merged.insert("permissions".into(), Value::Object(permissions));
Value::Object(merged)
}

/// Removes `key` from `map`: its object, or an empty one.
fn take_object(
map: &mut serde_json::Map<String, Value>,
key: &str,
) -> serde_json::Map<String, Value> {
match map.remove(key) {
Some(Value::Object(o)) => o,
_ => serde_json::Map::new(),
}
}

/// Claude's trailer in commits that credit people too. Claude Code's own
/// names the model, which isn't known before Claude starts; Claude Code
/// writes this one itself when it can't tell the model's version.
Expand Down Expand Up @@ -470,6 +586,65 @@ mod tests {
assert!(s.get("apiKeyHelper").is_none());
}

#[test]
fn the_images_settings_stay_under_fctls() {
let image = json!({
"enabledPlugins": { "rust@acme": true },
"extraKnownMarketplaces": { "acme": { "source": { "source": "github", "repo": "acme/plugins" } } },
"hooks": { "PostToolUse": [] },
"permissions": {
"allow": ["Bash(cargo:*)"],
"defaultMode": "plan",
"disableBypassPermissionsMode": "disable"
},
"env": {
"RUST_LOG": "info",
"ANTHROPIC_API_KEY": "sk-ant-x",
"ANTHROPIC_BASE_URL": "https://proxy",
"CLAUDE_CODE_USE_BEDROCK": "1",
"DISABLE_AUTOUPDATER": "0"
},
"apiKeyHelper": "echo sk-ant-x",
"attribution": { "commit": "Made by acme" },
"disableClaudeAiConnectors": true,
"model": "sonnet"
});
let s = merge_user_settings(&image, &user_settings(&project(true), &[]));
assert_eq!(s["enabledPlugins"], image["enabledPlugins"]);
assert_eq!(s["extraKnownMarketplaces"], image["extraKnownMarketplaces"]);
assert_eq!(s["hooks"], image["hooks"]);
assert_eq!(s["model"], "sonnet");
assert_eq!(s["permissions"]["allow"], json!(["Bash(cargo:*)"]));
assert_eq!(s["permissions"]["defaultMode"], "bypassPermissions");
assert!(
s["permissions"]
.get("disableBypassPermissionsMode")
.is_none()
);
assert_eq!(
s["env"],
json!({ "RUST_LOG": "info", "DISABLE_AUTOUPDATER": "1" })
);
assert!(s.get("apiKeyHelper").is_none());
// The project's settings decide credits and connectors.
assert_eq!(s["attribution"], json!({ "pr": "" }));
assert!(s.get("disableClaudeAiConnectors").is_none());
// A resume merges into what the last run wrote: the same again.
let again = merge_user_settings(&s, &user_settings(&project(true), &[]));
assert_eq!(again, s);
// A turned-off setting comes back on once the project says so.
let off = merge_user_settings(&s, &user_settings(&project(false), &[]));
assert_eq!(off["attribution"]["sessionUrl"], false);
let on = merge_user_settings(&off, &user_settings(&project(true), &[]));
assert!(on["attribution"].get("sessionUrl").is_none());
// No settings, or ones that aren't an object: fctl's alone.
for nothing in [Value::Null, json!([1])] {
let ours = user_settings(&project(true), &[]);
let s = merge_user_settings(&nothing, &ours);
assert_eq!(s, ours);
}
}

#[test]
fn user_settings_turn_connectors_off() {
assert!(
Expand Down Expand Up @@ -605,6 +780,54 @@ mod tests {
Ok(())
}

#[test]
fn reads_claude_codes_version() {
assert_eq!(
Version::parse("2.1.302 (Claude Code)\n"),
Some(Version(2, 1, 302))
);
assert_eq!(
Version::parse("2.1.999 (fake-claude)"),
Some(Version(2, 1, 999))
);
assert_eq!(
Version::parse("claude v2.1.101-rc.1"),
Some(Version(2, 1, 101))
);
assert_eq!(Version::parse("Claude Code"), None);
assert!(Version(2, 1, 100) < MIN_VERSION && MIN_VERSION < Version(2, 2, 0));
assert_eq!(MIN_VERSION.to_string(), "2.1.101");
}

#[tokio::test]
async fn sessions_need_a_recent_claude_code() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir()?;
let claude = |version: &str| -> anyhow::Result<String> {
let bin = dir.path().join(format!("claude-{version}"));
std::fs::write(&bin, format!("#!/bin/sh\necho '{version} (Claude Code)'\n"))?;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
Ok(bin.display().to_string())
};
assert_eq!(
check_version(&claude("2.1.302")?).await,
Ok(Some(Version(2, 1, 302)))
);
assert_eq!(
check_version(&claude("2.1.90")?).await,
Err("This sandbox has Claude Code 2.1.90, and fctl needs 2.1.101 or later: update it in the image (ghcr.io/hyperstructure/sandbox has the newest)".into())
);
let missing = check_version("fctl-test-no-claude").await;
assert!(
missing
.as_ref()
.is_err_and(|e| e.starts_with("This sandbox has no Claude Code")),
"{missing:?}"
);
assert_eq!(check_version(&claude("unknown")?).await, Ok(None));
Ok(())
}

#[test]
fn truncates_on_char_boundaries() {
assert_eq!(truncate("héllo", 2), "h…");
Expand Down
36 changes: 30 additions & 6 deletions crates/fctl-agent/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ pub struct Supervisor {
pending_files: Vec<(Attachment, String)>,
/// Said once that the sandbox's fctl cannot rename the session.
told_no_fctl: bool,
/// The sandbox's Claude Code, once checked.
claude_version: Option<claude::Version>,
/// `fctl publish` calls waiting for the host's answer, by request id.
publishing: HashMap<String, (oneshot::Sender<SessionReply>, Instant)>,
/// Publishes the caller stopped waiting for: if the host publishes one
Expand Down Expand Up @@ -173,6 +175,7 @@ impl Supervisor {
last_git: None,
pending_files: Vec::new(),
told_no_fctl: false,
claude_version: None,
publishing: HashMap::new(),
late_publishes: VecDeque::new(),
},
Expand Down Expand Up @@ -235,7 +238,7 @@ impl Supervisor {
configured: self.config.is_some(),
status: self.state.status,
agent_seq: self.journal.last_seq(),
claude_version: None,
claude_version: self.claude_version.map(|v| v.to_string()),
features: vec![FEATURE_ATTACHMENTS.to_owned()],
})
}
Expand Down Expand Up @@ -768,10 +771,24 @@ impl Supervisor {
cfg["oauthAccount"] = raw;
}
std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
std::fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&claude::user_settings(project, co_authors))?,
)?;
// The image's settings stay, with fctl's over them.
let settings_path = claude_dir.join("settings.json");
let image = match std::fs::read_to_string(&settings_path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
let kept = claude_dir.join("settings.json.invalid");
tracing::warn!(
"{} is not JSON ({e}); keeping it as {}",
settings_path.display(),
kept.display()
);
let _ = std::fs::rename(&settings_path, &kept);
Value::Null
}),
Err(_) => Value::Null,
};
let settings =
claude::merge_user_settings(&image, &claude::user_settings(project, co_authors));
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(())
}

Expand All @@ -781,6 +798,14 @@ impl Supervisor {
};
let spec = &config.spec;
let workdir = config.workdir.clone();
let bin = config.claude_bin.clone().unwrap_or_else(|| "claude".into());
if self.claude_version.is_none() {
match claude::check_version(&bin).await {
Ok(Some(version)) => self.claude_version = Some(version),
Ok(None) => tracing::warn!("{bin} --version said no version; starting it anyway"),
Err(why) => anyhow::bail!(why),
}
}
self.write_claude_config(
&paths.home,
&workdir,
Expand Down Expand Up @@ -855,7 +880,6 @@ impl Supervisor {
|| claude::conversation_exists(&paths.home, &spec.claude_session_id));
let fresh_resume = wants_resume && !resume;
let system = prompt::system_prompt(spec, &workdir, &paths, config.sandbox);
let bin = config.claude_bin.clone().unwrap_or_else(|| "claude".into());
let launch = LaunchSpec {
bin: &bin,
cwd: &paths.workdir,
Expand Down
Loading
Loading