2026-03-04 07:43:01 -08:00
|
|
|
use tauri::{Emitter, State};
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-04-15 06:54:59 -07:00
|
|
|
use crate::commands::aws_commands;
|
2026-02-27 04:29:51 +00:00
|
|
|
use crate::docker;
|
2026-08-09 18:19:12 -07:00
|
|
|
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
2026-02-27 04:29:51 +00:00
|
|
|
use crate::storage::secure;
|
|
|
|
|
use crate::AppState;
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
2026-03-04 07:43:01 -08:00
|
|
|
let _ = app_handle.emit(
|
|
|
|
|
"container-progress",
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"project_id": project_id,
|
|
|
|
|
"message": message,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:12:48 -07:00
|
|
|
/// Every project secret, as the JSON pointer it arrives under and the keychain
|
|
|
|
|
/// key it is stored as.
|
|
|
|
|
///
|
|
|
|
|
/// The keychain keys are the ones already in users' keychains — changing one
|
|
|
|
|
/// orphans the secret rather than migrating it.
|
|
|
|
|
const PROJECT_SECRET_FIELDS: &[(&str, &str)] = &[
|
|
|
|
|
("/git_token", "git-token"),
|
|
|
|
|
("/bedrock_config/aws_access_key_id", "aws-access-key-id"),
|
|
|
|
|
("/bedrock_config/aws_secret_access_key", "aws-secret-access-key"),
|
|
|
|
|
("/bedrock_config/aws_session_token", "aws-session-token"),
|
|
|
|
|
("/bedrock_config/aws_bearer_token", "aws-bearer-token"),
|
|
|
|
|
("/openai_compatible_config/api_key", "openai-compatible-api-key"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// The keychain keys the caller sent an explicit `null` for.
|
|
|
|
|
///
|
|
|
|
|
/// **Absent and `null` are different things here, and treating them alike
|
|
|
|
|
/// destroys credentials.** Every secret field is `#[serde(skip_serializing)]`,
|
|
|
|
|
/// so the `Project` the frontend holds has no `git_token` key at all — a save
|
|
|
|
|
/// from the Workspace, Runtime or Model section spreads that object and sends
|
|
|
|
|
/// the field *absent*, while the editor that owns the field sends
|
|
|
|
|
/// `git_token: null` when the user blanks it (`AccessSection.tsx`:
|
|
|
|
|
/// `save({ git_token: gitToken || null })`). Serde maps both to `None`, which
|
|
|
|
|
/// is why this reads the payload rather than the deserialised struct: absent
|
|
|
|
|
/// means "not mine to touch", `null` means "the user emptied it".
|
|
|
|
|
fn explicitly_cleared_secrets(payload: &serde_json::Value) -> Vec<&'static str> {
|
|
|
|
|
PROJECT_SECRET_FIELDS
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(pointer, _)| matches!(payload.pointer(pointer), Some(serde_json::Value::Null)))
|
|
|
|
|
.map(|(_, key)| *key)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Store one secret, clear it, or leave it alone — see
|
|
|
|
|
/// [`explicitly_cleared_secrets`] for which is which.
|
|
|
|
|
fn save_secret(
|
|
|
|
|
project_id: &str,
|
|
|
|
|
key_name: &str,
|
|
|
|
|
value: Option<&str>,
|
|
|
|
|
explicitly_cleared: &[&str],
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
if value.is_none() && !explicitly_cleared.contains(&key_name) {
|
|
|
|
|
return Ok(());
|
2026-02-28 20:42:55 +00:00
|
|
|
}
|
2026-08-23 13:12:48 -07:00
|
|
|
secure::store_or_clear_project_secret(project_id, key_name, value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract secret fields from a project and store them in the OS keychain,
|
|
|
|
|
/// **deleting the ones the caller blanked**.
|
|
|
|
|
///
|
|
|
|
|
/// The `if let Some(v) = …` this replaces could only ever write: a blanked
|
|
|
|
|
/// token left the old value in the keychain, `load_secrets_for_project` read it
|
|
|
|
|
/// straight back onto the project, and the container went on getting the
|
|
|
|
|
/// credential the user had just revoked.
|
|
|
|
|
fn store_secrets_for_project(project: &Project, explicitly_cleared: &[&str]) -> Result<(), String> {
|
|
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"git-token",
|
|
|
|
|
project.git_token.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
2026-02-28 20:42:55 +00:00
|
|
|
if let Some(ref bedrock) = project.bedrock_config {
|
2026-08-23 13:12:48 -07:00
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"aws-access-key-id",
|
|
|
|
|
bedrock.aws_access_key_id.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
|
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"aws-secret-access-key",
|
|
|
|
|
bedrock.aws_secret_access_key.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
|
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"aws-session-token",
|
|
|
|
|
bedrock.aws_session_token.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
|
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"aws-bearer-token",
|
|
|
|
|
bedrock.aws_bearer_token.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
2026-02-28 20:42:55 +00:00
|
|
|
}
|
2026-03-13 06:16:05 -07:00
|
|
|
if let Some(ref oai_config) = project.openai_compatible_config {
|
2026-08-23 13:12:48 -07:00
|
|
|
save_secret(
|
|
|
|
|
&project.id,
|
|
|
|
|
"openai-compatible-api-key",
|
|
|
|
|
oai_config.api_key.as_deref(),
|
|
|
|
|
explicitly_cleared,
|
|
|
|
|
)?;
|
2026-03-11 13:05:52 -07:00
|
|
|
}
|
2026-02-28 20:42:55 +00:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
/// Create the project's container, threading every global setting through.
|
|
|
|
|
///
|
|
|
|
|
/// Exists so that the two ordinary create paths below and base-image migration
|
|
|
|
|
/// cannot drift apart — a container created by a migration must be
|
|
|
|
|
/// indistinguishable from one created by a normal start, or the next
|
|
|
|
|
/// `container_needs_recreation` would immediately throw it away.
|
|
|
|
|
///
|
|
|
|
|
/// `create_image` is what to create *from* (the snapshot or the base);
|
|
|
|
|
/// `base_image_name` is the configured base, which `create_container` needs in
|
|
|
|
|
/// order to tell those two apart when it stamps the lineage labels.
|
|
|
|
|
pub(crate) async fn create_container_for_project(
|
|
|
|
|
project: &Project,
|
|
|
|
|
settings: &AppSettings,
|
|
|
|
|
docker_socket: &str,
|
|
|
|
|
aws_config_path: Option<&str>,
|
|
|
|
|
create_image: &str,
|
|
|
|
|
base_image_name: &str,
|
|
|
|
|
extras: docker::CreateExtras<'_>,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
docker::create_container(
|
|
|
|
|
project,
|
|
|
|
|
docker_socket,
|
|
|
|
|
create_image,
|
|
|
|
|
base_image_name,
|
|
|
|
|
extras,
|
|
|
|
|
aws_config_path,
|
|
|
|
|
&settings.global_aws,
|
|
|
|
|
&settings.global_ollama,
|
|
|
|
|
&settings.global_llamacpp,
|
|
|
|
|
&settings.global_openai_compatible,
|
|
|
|
|
settings.global_claude_instructions.as_deref(),
|
|
|
|
|
&settings.global_custom_env_vars,
|
|
|
|
|
settings.timezone.as_deref(),
|
|
|
|
|
settings.global_claude_code_settings.as_ref(),
|
|
|
|
|
settings.default_ssh_key_path.as_deref(),
|
2026-08-10 10:40:21 -07:00
|
|
|
settings.ca_cert_path.as_deref(),
|
2026-08-09 18:19:12 -07:00
|
|
|
settings.default_git_user_name.as_deref(),
|
|
|
|
|
settings.default_git_user_email.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
/// Populate secret fields on a project struct from the OS keychain.
|
2026-08-09 18:19:12 -07:00
|
|
|
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
2026-02-28 20:42:55 +00:00
|
|
|
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
|
|
|
|
.unwrap_or(None);
|
|
|
|
|
if let Some(ref mut bedrock) = project.bedrock_config {
|
|
|
|
|
bedrock.aws_access_key_id = secure::get_project_secret(&project.id, "aws-access-key-id")
|
|
|
|
|
.unwrap_or(None);
|
|
|
|
|
bedrock.aws_secret_access_key = secure::get_project_secret(&project.id, "aws-secret-access-key")
|
|
|
|
|
.unwrap_or(None);
|
|
|
|
|
bedrock.aws_session_token = secure::get_project_secret(&project.id, "aws-session-token")
|
|
|
|
|
.unwrap_or(None);
|
|
|
|
|
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
|
|
|
|
.unwrap_or(None);
|
|
|
|
|
}
|
2026-03-13 06:16:05 -07:00
|
|
|
if let Some(ref mut oai_config) = project.openai_compatible_config {
|
|
|
|
|
oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key")
|
2026-03-11 13:05:52 -07:00
|
|
|
.unwrap_or(None);
|
|
|
|
|
}
|
2026-02-28 20:42:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:11:01 -07:00
|
|
|
/// Validate the folder list a project is about to be stored with.
|
|
|
|
|
///
|
|
|
|
|
/// ## Why this is not cosmetic
|
|
|
|
|
///
|
|
|
|
|
/// `mount_name` is interpolated straight into a container mount target —
|
|
|
|
|
/// `docker::create_container` builds `/workspace/{mount_name}` — and the daemon
|
|
|
|
|
/// **normalises** what it is given. Confirmed against Engine 29.7 through the
|
|
|
|
|
/// same API bollard uses: a mount named `../tmp/claude-x` is created with a
|
|
|
|
|
/// destination of `/tmp/claude-x`, i.e. the host directory is mounted straight
|
|
|
|
|
/// on top of one of the paths the pre-commit scrub owns. The next recreate then
|
|
|
|
|
/// runs the scrub, as root, over the user's own project directory. That is the
|
2026-08-23 15:42:20 -07:00
|
|
|
/// C1 data-loss chain end to end, and
|
|
|
|
|
/// [`check_mount_name_stays_under_workspace`] is the half of it that stops the
|
|
|
|
|
/// path ever being spelled.
|
2026-08-23 13:11:01 -07:00
|
|
|
///
|
|
|
|
|
/// `host_path` is the other side of the same mount. `/` there bind-mounts the
|
|
|
|
|
/// entire host filesystem read-write into a container whose agent has
|
|
|
|
|
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
|
|
|
|
/// a folder — the Browse button and the free-text field lead to the same place
|
|
|
|
|
/// — so only the roots themselves are refused.
|
2026-08-23 15:42:20 -07:00
|
|
|
///
|
|
|
|
|
/// This is the **whole** rule set, and it belongs to `add_project`, where every
|
|
|
|
|
/// row is new by definition. `update_project` runs
|
|
|
|
|
/// [`validate_project_paths_update`] instead: see there for why a list that is
|
|
|
|
|
/// already in `projects.json` cannot be held to all of it.
|
2026-08-23 13:11:01 -07:00
|
|
|
fn validate_project_paths(paths: &[ProjectPath]) -> Result<(), String> {
|
|
|
|
|
let mut seen_names = std::collections::HashSet::new();
|
|
|
|
|
for p in paths {
|
|
|
|
|
// The Config tab's "+ Add folder" inserts an empty row and saves the
|
|
|
|
|
// whole list on the next blur, so a wholly blank entry is the UI's
|
|
|
|
|
// placeholder rather than an attempt at anything. It is stored as it
|
|
|
|
|
// always was; a half-filled one is refused.
|
|
|
|
|
if p.host_path.is_empty() && p.mount_name.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 15:42:20 -07:00
|
|
|
validate_one_path(p)?;
|
|
|
|
|
if !seen_names.insert(p.mount_name.clone()) {
|
|
|
|
|
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
|
2026-08-23 13:11:01 -07:00
|
|
|
}
|
2026-08-23 15:42:20 -07:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every rule that applies to a single folder row, duplicates aside.
|
|
|
|
|
fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
|
|
|
|
|
if p.mount_name.is_empty() {
|
|
|
|
|
return Err("Mount name cannot be empty.".to_string());
|
|
|
|
|
}
|
|
|
|
|
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
|
|
|
|
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
|
|
|
|
}
|
|
|
|
|
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
|
|
|
|
if p.host_path.is_empty() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Folder mounted at '/workspace/{}' has no host path.",
|
|
|
|
|
p.mount_name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if is_filesystem_root(&p.host_path) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
|
|
|
|
|
p.host_path
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The one rule that survives every exemption: the mount has to land under
|
|
|
|
|
/// `/workspace`.
|
|
|
|
|
///
|
|
|
|
|
/// A name carrying a path separator, or one that is nothing but dots, does not
|
|
|
|
|
/// name a folder inside `/workspace` — it moves the mount. `/workspace/..` is
|
|
|
|
|
/// `/`, `/workspace/../tmp/claude-x` normalises to `/tmp/claude-x`, and
|
|
|
|
|
/// `/workspace/.` is `/workspace` itself, shadowing every other mount. The
|
|
|
|
|
/// first of those is the C1 chain: a host directory mounted over a path the
|
|
|
|
|
/// pre-commit scrub empties as root.
|
|
|
|
|
///
|
|
|
|
|
/// Everything else `validate_one_path` checks is hygiene — a space or an `@` in
|
|
|
|
|
/// a mount name is untidy, not an escape — which is why only this one is
|
|
|
|
|
/// applied to rows [`validate_project_paths_update`] otherwise grandfathers.
|
|
|
|
|
fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String> {
|
|
|
|
|
if mount_name.contains('/') || mount_name.contains('\\') {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Mount name '{}' contains a path separator, so the folder would be mounted somewhere \
|
|
|
|
|
/workspace/{{name}} does not reach. Use a plain folder name.",
|
|
|
|
|
mount_name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
|
|
|
|
mount_name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate the folder list of a project that **already exists**, admitting the
|
|
|
|
|
/// rows it is already stored with.
|
|
|
|
|
///
|
|
|
|
|
/// ## Why this is not just [`validate_project_paths`]
|
|
|
|
|
///
|
|
|
|
|
/// `update_project` validated nothing at all until recently, while
|
|
|
|
|
/// `WorkspaceSection` saved `{paths}` on every blur — so `projects.json` files
|
|
|
|
|
/// in the field hold rows that the full rule set refuses: a half-filled row, a
|
|
|
|
|
/// mount name with a space in it, two rows sharing a name, `/` as a host path.
|
|
|
|
|
///
|
|
|
|
|
/// Running the full check on every save made every such project **entirely
|
|
|
|
|
/// unsavable**. Not just its folder list: `update_project` is the one command
|
|
|
|
|
/// behind the Config tab, so every toggle, every permission-mode change and
|
|
|
|
|
/// `useTerminal.ts`'s tab rename came back with a message about folders. The
|
|
|
|
|
/// remedy exists — fix the row in the Workspace section — but nothing about
|
|
|
|
|
/// "cannot save" on a sandbox switch points at it.
|
|
|
|
|
///
|
|
|
|
|
/// And blocking the save bought nothing for the rows it was blocking. They are
|
|
|
|
|
/// *already stored*, and already mounted on every container start; refusing to
|
|
|
|
|
/// persist an unrelated field does not unmount them.
|
|
|
|
|
///
|
|
|
|
|
/// So: a row carried over verbatim from what is stored is admitted, and a row
|
|
|
|
|
/// that is new or edited is held to every rule. That is enough to keep the
|
|
|
|
|
/// escalation closed, because escalation means *introducing* a bad value
|
|
|
|
|
/// through this command, and an introduced row is never a carried-over one.
|
|
|
|
|
///
|
|
|
|
|
/// The single exception is [`check_mount_name_stays_under_workspace`], which
|
|
|
|
|
/// runs on every row either way. A stored `..` is a live data-loss chain rather
|
|
|
|
|
/// than untidy data, its remedy is one edit in the Workspace section, and the
|
|
|
|
|
/// message names the mount rather than talking about folders in the abstract.
|
|
|
|
|
fn validate_project_paths_update(
|
|
|
|
|
stored: &[ProjectPath],
|
|
|
|
|
incoming: &[ProjectPath],
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let is_blank = |p: &ProjectPath| p.host_path.is_empty() && p.mount_name.is_empty();
|
|
|
|
|
|
|
|
|
|
// Rows carried over, counted rather than set-tested: a *second* copy of an
|
|
|
|
|
// existing row is a new row, and has to be checked like one.
|
|
|
|
|
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
for p in stored.iter().filter(|p| !is_blank(p)) {
|
|
|
|
|
*carried
|
|
|
|
|
.entry((p.host_path.as_str(), p.mount_name.as_str()))
|
|
|
|
|
.or_insert(0) += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for p in incoming.iter().filter(|p| !is_blank(p)) {
|
|
|
|
|
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
|
|
|
|
|
|
|
|
|
match carried.get_mut(&(p.host_path.as_str(), p.mount_name.as_str())) {
|
|
|
|
|
Some(remaining) if *remaining > 0 => {
|
|
|
|
|
*remaining -= 1;
|
|
|
|
|
log::debug!(
|
|
|
|
|
"Admitting a stored folder row unchanged: '{}' at /workspace/{}",
|
|
|
|
|
p.host_path,
|
|
|
|
|
p.mount_name
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
_ => validate_one_path(p)?,
|
2026-08-23 13:11:01 -07:00
|
|
|
}
|
2026-08-23 15:42:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Duplicates get the same treatment, one level up: a name may repeat as
|
|
|
|
|
// many times as it already did, and no more. Counting both sides keeps the
|
|
|
|
|
// answer independent of the order the rows arrive in.
|
|
|
|
|
let count_names = |rows: &[ProjectPath]| {
|
|
|
|
|
let mut counts: std::collections::HashMap<String, usize> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
for p in rows.iter().filter(|p| !is_blank(p)) {
|
|
|
|
|
*counts.entry(p.mount_name.clone()).or_insert(0) += 1;
|
2026-08-23 13:11:01 -07:00
|
|
|
}
|
2026-08-23 15:42:20 -07:00
|
|
|
counts
|
|
|
|
|
};
|
|
|
|
|
let stored_names = count_names(stored);
|
|
|
|
|
for (name, count) in count_names(incoming) {
|
|
|
|
|
let allowed = stored_names.get(&name).copied().unwrap_or(0).max(1);
|
|
|
|
|
if count > allowed {
|
|
|
|
|
return Err(format!("Duplicate mount name '{}'.", name));
|
2026-08-23 13:11:01 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 15:42:20 -07:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Refuse a filesystem root newly set as `ssh_key_path` or `ca_cert_path`.
|
|
|
|
|
///
|
|
|
|
|
/// Both are bind-mounted into the container by `docker::create_container` —
|
|
|
|
|
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
|
|
|
|
|
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
|
|
|
|
|
/// this is disclosure rather than the read-write hole a `/` project folder is,
|
|
|
|
|
/// but the fix is the same one line.
|
|
|
|
|
///
|
|
|
|
|
/// Same grandfathering as the folder list, for the same reason: a value already
|
|
|
|
|
/// stored is already mounted on every start, and refusing an unrelated save
|
|
|
|
|
/// does not unmount it. Only a *change* is held to the rule.
|
|
|
|
|
fn validate_mounted_host_path(
|
|
|
|
|
label: &str,
|
|
|
|
|
stored: Option<&str>,
|
|
|
|
|
incoming: Option<&str>,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let Some(value) = incoming.map(str::trim).filter(|v| !v.is_empty()) else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
if stored.map(str::trim) == Some(value) {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
if is_filesystem_root(value) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
|
|
|
|
|
container. Choose the folder itself.",
|
|
|
|
|
value, label
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-08-23 13:11:01 -07:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a host path is the root of a filesystem, in any spelling the three
|
|
|
|
|
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
|
|
|
|
|
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
|
|
|
|
|
/// answer.
|
|
|
|
|
fn is_filesystem_root(host_path: &str) -> bool {
|
|
|
|
|
let trimmed = host_path.trim_end_matches(['/', '\\']);
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
// Nothing but separators: `/`, `\\`, `//`.
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// `C:` — a drive with no path on it.
|
|
|
|
|
let bytes = trimmed.as_bytes();
|
|
|
|
|
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
|
|
|
|
Ok(state.projects_store.list())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn add_project(
|
|
|
|
|
name: String,
|
2026-02-28 21:18:33 +00:00
|
|
|
paths: Vec<ProjectPath>,
|
2026-02-27 04:29:51 +00:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Project, String> {
|
2026-02-28 21:18:33 +00:00
|
|
|
// Validate paths
|
2026-08-23 13:11:01 -07:00
|
|
|
// A new project needs a folder; the blank row `validate_project_paths`
|
|
|
|
|
// tolerates is the Config tab's placeholder on an *existing* one.
|
|
|
|
|
if paths.is_empty()
|
|
|
|
|
|| paths
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|p| p.host_path.is_empty() && p.mount_name.is_empty())
|
|
|
|
|
{
|
2026-02-28 21:18:33 +00:00
|
|
|
return Err("At least one folder path is required.".to_string());
|
|
|
|
|
}
|
2026-08-23 13:11:01 -07:00
|
|
|
validate_project_paths(&paths)?;
|
2026-02-28 21:18:33 +00:00
|
|
|
let project = Project::new(name, paths);
|
2026-08-23 13:12:48 -07:00
|
|
|
// Nothing can have been blanked on a project that did not exist a line ago.
|
|
|
|
|
store_secrets_for_project(&project, &[])?;
|
2026-02-27 04:29:51 +00:00
|
|
|
state.projects_store.add(project)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn remove_project(
|
|
|
|
|
project_id: String,
|
|
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<(), String> {
|
2026-08-23 13:11:01 -07:00
|
|
|
// **H-2: the only writer of these three categories that held nothing.**
|
|
|
|
|
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
|
|
|
|
// both named volumes — and a compaction resolves that same tag when its
|
|
|
|
|
// build starts and commits back over it minutes later. Compact a project,
|
|
|
|
|
// then remove it from the sidebar, and `restore_image_config` commits a
|
|
|
|
|
// flat image back onto `triple-c-snapshot-{id}:latest` for a project that
|
|
|
|
|
// no longer exists: not dangling, not a `:pre-migration-*` tag, and not
|
|
|
|
|
// reachable by the per-project scan, so no reclaim path can ever see it
|
|
|
|
|
// again. Taken for the whole removal, like every other writer.
|
|
|
|
|
//
|
|
|
|
|
// Refusing is safe for the UI: `useProjects.remove` only drops the sidebar
|
|
|
|
|
// row *after* the command resolves, and `ProjectHome` turns the rejection
|
|
|
|
|
// into a toast, so the row stays and the message names what is running.
|
|
|
|
|
let _guard =
|
|
|
|
|
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Destroy)?;
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
// Release any host loopback ports the auth bridge holds for this project
|
|
|
|
|
// before the container (and the project record) go away.
|
|
|
|
|
state.auth_bridge.stop(&project_id).await;
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
// A migration record outliving its project leaks a state file, a staged
|
|
|
|
|
// payload tar that can run to several GB, and a `:pre-migration-<ts>` tag
|
|
|
|
|
// holding an entire snapshot image that nothing will ever reference again.
|
|
|
|
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
// Stop and remove container if it exists
|
2026-03-03 16:16:19 -08:00
|
|
|
if let Some(ref project) = state.projects_store.get(&project_id) {
|
2026-02-27 04:29:51 +00:00
|
|
|
if let Some(ref container_id) = project.container_id {
|
2026-02-27 19:54:44 +00:00
|
|
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
2026-02-27 04:29:51 +00:00
|
|
|
let _ = docker::stop_container(container_id).await;
|
|
|
|
|
let _ = docker::remove_container(container_id).await;
|
|
|
|
|
}
|
2026-03-04 10:21:05 -08:00
|
|
|
|
2026-08-09 10:31:18 -07:00
|
|
|
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
|
|
|
|
// containers first, then the per-project network they were attached to.
|
|
|
|
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
|
|
|
|
docker::remove_legacy_project_network(&project.id).await;
|
2026-03-04 10:21:05 -08:00
|
|
|
|
2026-03-03 16:16:19 -08:00
|
|
|
// Clean up the snapshot image + volumes
|
|
|
|
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
|
|
|
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = docker::remove_project_volumes(project).await {
|
|
|
|
|
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
// Clean up keychain secrets for this project
|
|
|
|
|
if let Err(e) = secure::delete_project_secrets(&project_id) {
|
|
|
|
|
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
state.projects_store.remove(&project_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn update_project(
|
2026-08-23 13:12:48 -07:00
|
|
|
project: serde_json::Value,
|
2026-08-09 11:35:42 -07:00
|
|
|
app_handle: tauri::AppHandle,
|
2026-02-27 04:29:51 +00:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Project, String> {
|
2026-08-23 13:12:48 -07:00
|
|
|
// Taken as raw JSON, then deserialised, for one reason: a secret field that
|
|
|
|
|
// arrived as `null` is a credential the user cleared, and a secret field
|
|
|
|
|
// that did not arrive at all belongs to whichever editor is not saving
|
|
|
|
|
// right now. `Option<String>` cannot tell those apart — see
|
|
|
|
|
// [`explicitly_cleared_secrets`].
|
|
|
|
|
let explicitly_cleared = explicitly_cleared_secrets(&project);
|
|
|
|
|
let mut project: Project = serde_json::from_value(project)
|
|
|
|
|
.map_err(|e| format!("Could not read the project being saved: {}", e))?;
|
|
|
|
|
|
2026-08-23 13:11:01 -07:00
|
|
|
// Fields this command does not get to write, whoever is calling it.
|
|
|
|
|
//
|
|
|
|
|
// `container_id` is the one that matters: it is the handle the whole file
|
|
|
|
|
// command surface resolves against, `list_sibling_containers` hands the
|
|
|
|
|
// webview the ids of every other container on the daemon, and a project
|
|
|
|
|
// save is not the place a container is adopted. It is assigned by
|
|
|
|
|
// `start_project_container` through `projects_store::set_container_id` and
|
|
|
|
|
// read back here. `status` has its own setter (`update_status`) for the
|
|
|
|
|
// same reason, and neither `id` nor `created_at` is a thing a save can
|
|
|
|
|
// mean to change.
|
|
|
|
|
//
|
|
|
|
|
// The privileged *toggles* — `allow_docker_access`, `vpn_support_enabled`,
|
|
|
|
|
// `sandbox_mode_enabled`, `permission_mode` — are deliberately not in this
|
|
|
|
|
// list: they are what the Config tab's own switches write, through this
|
|
|
|
|
// command, and there is nothing here that can tell that call apart from
|
|
|
|
|
// any other. Their boundary is the webview, not this function.
|
|
|
|
|
let stored = state
|
|
|
|
|
.projects_store
|
|
|
|
|
.get(&project.id)
|
|
|
|
|
.ok_or_else(|| format!("Project {} not found", project.id))?;
|
2026-08-23 15:42:20 -07:00
|
|
|
|
|
|
|
|
// **This takes a whole `Project` over IPC and used to store it verbatim.**
|
|
|
|
|
// `add_project` validated its folder list and this did not, so every check
|
|
|
|
|
// there was one edit away from being bypassed — and the Config tab's mount
|
|
|
|
|
// name is a free-text field on an existing project, saved on blur, calling
|
|
|
|
|
// exactly this command. See [`validate_project_paths`] for what a mount
|
|
|
|
|
// name of `../tmp/claude-x` does to the user's files.
|
|
|
|
|
//
|
|
|
|
|
// Validated against what is *stored*, not in isolation: a rule this command
|
|
|
|
|
// never enforced can be violated by data already on disk, and a project
|
|
|
|
|
// that cannot be saved at all is a Config tab that cannot be used at all.
|
|
|
|
|
// See [`validate_project_paths_update`].
|
|
|
|
|
validate_project_paths_update(&stored.paths, &project.paths)?;
|
|
|
|
|
validate_mounted_host_path(
|
|
|
|
|
"the SSH key folder",
|
|
|
|
|
stored.ssh_key_path.as_deref(),
|
|
|
|
|
project.ssh_key_path.as_deref(),
|
|
|
|
|
)?;
|
|
|
|
|
validate_mounted_host_path(
|
|
|
|
|
"the CA certificate path",
|
|
|
|
|
stored.ca_cert_path.as_deref(),
|
|
|
|
|
project.ca_cert_path.as_deref(),
|
|
|
|
|
)?;
|
|
|
|
|
|
2026-08-23 13:11:01 -07:00
|
|
|
project.container_id = stored.container_id;
|
|
|
|
|
project.status = stored.status;
|
|
|
|
|
project.created_at = stored.created_at;
|
|
|
|
|
project.updated_at = chrono::Utc::now().to_rfc3339();
|
|
|
|
|
|
2026-08-23 13:12:48 -07:00
|
|
|
store_secrets_for_project(&project, &explicitly_cleared)?;
|
2026-08-09 11:35:42 -07:00
|
|
|
let updated = state.projects_store.update(project)?;
|
|
|
|
|
|
|
|
|
|
// `auth_bridge_enabled` can arrive through this generic save as well as
|
|
|
|
|
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
|
|
|
|
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
|
|
|
|
// when nothing is running, so this is safe on every project save.
|
|
|
|
|
if updated.auth_bridge_enabled {
|
|
|
|
|
if let Some(ref container_id) = updated.container_id {
|
|
|
|
|
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
|
|
|
|
state
|
|
|
|
|
.auth_bridge
|
|
|
|
|
.start(
|
|
|
|
|
updated.id.clone(),
|
|
|
|
|
container_id.clone(),
|
|
|
|
|
app_handle,
|
|
|
|
|
state.projects_store.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
state.auth_bridge.stop(&updated.id).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(updated)
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn start_project_container(
|
|
|
|
|
project_id: String,
|
2026-03-04 07:43:01 -08:00
|
|
|
app_handle: tauri::AppHandle,
|
2026-02-27 04:29:51 +00:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Project, String> {
|
2026-08-23 11:45:28 -07:00
|
|
|
// **Acquired, not polled.** A migration removes the container and creates
|
|
|
|
|
// its replacement moments later. Starting in that window finds no
|
|
|
|
|
// container, creates a second one under the same name, and the migration's
|
|
|
|
|
// own create then fails on the name conflict — which sends it into an
|
|
|
|
|
// auto-rollback that also cannot create. This used to be a one-shot
|
|
|
|
|
// `is_migrating` read, which covered that case and no other: a start also
|
|
|
|
|
// commits `triple-c-snapshot-{id}:latest`, so it races a *compaction*
|
|
|
|
|
// committing the same tag with nothing between them. The claim is held for
|
|
|
|
|
// the whole start rather than checked at its door.
|
|
|
|
|
//
|
|
|
|
|
// The UI already refuses (`canMigrate` gates on the container being stopped
|
|
|
|
|
// and no run being in flight); this is the same gate on the side that
|
|
|
|
|
// actually owns the invariant.
|
|
|
|
|
let _guard =
|
|
|
|
|
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Recreate)?;
|
|
|
|
|
start_project_container_locked(project_id, app_handle, state).await
|
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
|
2026-08-23 11:45:28 -07:00
|
|
|
/// The body of [`start_project_container`], with **no claim of its own**.
|
|
|
|
|
///
|
|
|
|
|
/// Split out for exactly one caller: [`rebuild_project_container`] already
|
|
|
|
|
/// holds the project under [`crate::project_lock::ProjectOp::Reset`] for its
|
|
|
|
|
/// whole run, and a Reset that then went through the public command would be
|
|
|
|
|
/// refused by its own guard. Every other path must go through the command.
|
|
|
|
|
async fn start_project_container_locked(
|
|
|
|
|
project_id: String,
|
|
|
|
|
app_handle: tauri::AppHandle,
|
|
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Project, String> {
|
2026-02-27 04:29:51 +00:00
|
|
|
let mut project = state
|
|
|
|
|
.projects_store
|
|
|
|
|
.get(&project_id)
|
|
|
|
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
// Populate secret fields from the OS keychain so they are available
|
|
|
|
|
// in memory when building environment variables for the container.
|
|
|
|
|
load_secrets_for_project(&mut project);
|
|
|
|
|
|
2026-02-27 15:22:49 +00:00
|
|
|
// Load settings for image resolution and global AWS
|
|
|
|
|
let settings = state.settings_store.get();
|
|
|
|
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
|
|
|
|
|
2026-03-12 09:26:58 -07:00
|
|
|
// Validate backend requirements
|
|
|
|
|
if project.backend == Backend::Bedrock {
|
2026-03-01 03:59:58 +00:00
|
|
|
let bedrock = project.bedrock_config.as_ref()
|
2026-03-12 09:26:58 -07:00
|
|
|
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
|
2026-03-01 03:59:58 +00:00
|
|
|
// Region can come from per-project or global
|
|
|
|
|
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
2026-03-12 09:26:58 -07:00
|
|
|
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
2026-03-01 03:59:58 +00:00
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-03-12 09:26:58 -07:00
|
|
|
if project.backend == Backend::Ollama {
|
2026-03-11 13:05:52 -07:00
|
|
|
let ollama = project.ollama_config.as_ref()
|
2026-03-12 09:26:58 -07:00
|
|
|
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
|
2026-05-24 08:49:06 -07:00
|
|
|
if ollama.base_url.is_empty()
|
|
|
|
|
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
|
|
|
|
{
|
|
|
|
|
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
|
2026-03-11 13:05:52 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
if project.backend == Backend::LlamaCpp {
|
|
|
|
|
let cfg = project.llamacpp_config.as_ref()
|
|
|
|
|
.ok_or_else(|| "llama.cpp backend selected but no llama.cpp configuration found.".to_string())?;
|
|
|
|
|
if cfg.base_url.trim().is_empty()
|
|
|
|
|
&& settings.global_llamacpp.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
|
|
|
|
{
|
|
|
|
|
return Err("llama.cpp base URL is required. Set it per-project or in global llama.cpp settings.".to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 06:16:05 -07:00
|
|
|
if project.backend == Backend::OpenAiCompatible {
|
|
|
|
|
let oai_config = project.openai_compatible_config.as_ref()
|
|
|
|
|
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
|
2026-05-24 08:49:06 -07:00
|
|
|
if oai_config.base_url.is_empty()
|
|
|
|
|
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
|
|
|
|
|
{
|
|
|
|
|
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
|
2026-03-11 13:05:52 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
// Update status to starting
|
|
|
|
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
|
|
|
|
|
2026-04-15 06:54:59 -07:00
|
|
|
// Pre-validate AWS SSO session on the host for Bedrock Profile projects.
|
|
|
|
|
// If the session is expired, trigger `aws sso login` before starting the container
|
|
|
|
|
// so the entrypoint copies already-fresh credentials from the host mount.
|
|
|
|
|
if project.backend == Backend::Bedrock {
|
|
|
|
|
if let Some(ref bedrock) = project.bedrock_config {
|
|
|
|
|
if bedrock.auth_method == BedrockAuthMethod::Profile {
|
|
|
|
|
let profile = aws_commands::resolve_profile_for_project(
|
|
|
|
|
&project,
|
|
|
|
|
settings.global_aws.aws_profile.as_deref(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
emit_progress(&app_handle, &project_id, "Validating AWS session...");
|
|
|
|
|
|
|
|
|
|
let session_valid = tokio::time::timeout(
|
|
|
|
|
std::time::Duration::from_secs(10),
|
|
|
|
|
aws_commands::check_sso_session(&profile),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match session_valid {
|
|
|
|
|
Ok(Ok(true)) => {
|
|
|
|
|
emit_progress(&app_handle, &project_id, "AWS session valid.");
|
|
|
|
|
}
|
|
|
|
|
Ok(Ok(false)) => {
|
|
|
|
|
// Session expired — check if this is an SSO profile
|
|
|
|
|
if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) {
|
|
|
|
|
emit_progress(
|
|
|
|
|
&app_handle,
|
|
|
|
|
&project_id,
|
|
|
|
|
"AWS session expired. Starting SSO login (check your browser)...",
|
|
|
|
|
);
|
|
|
|
|
match aws_commands::run_sso_login(&profile).await {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
emit_progress(
|
|
|
|
|
&app_handle,
|
|
|
|
|
&project_id,
|
|
|
|
|
"SSO login successful.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::warn!(
|
|
|
|
|
"SSO login failed for profile '{}': {} — continuing anyway",
|
|
|
|
|
profile,
|
|
|
|
|
e
|
|
|
|
|
);
|
|
|
|
|
emit_progress(
|
|
|
|
|
&app_handle,
|
|
|
|
|
&project_id,
|
|
|
|
|
"SSO login failed or cancelled. Continuing...",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
log::warn!(
|
|
|
|
|
"AWS session invalid for profile '{}' (not SSO). Continuing...",
|
|
|
|
|
profile
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(Err(e)) => {
|
|
|
|
|
log::warn!("Failed to check AWS session: {} — continuing anyway", e);
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
log::warn!("AWS session check timed out — continuing anyway");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
// Wrap container operations so that any failure resets status to Stopped.
|
|
|
|
|
let result: Result<String, String> = async {
|
|
|
|
|
// Ensure image exists
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Checking image...");
|
2026-02-28 20:42:55 +00:00
|
|
|
if !docker::image_exists(&image_name).await? {
|
|
|
|
|
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
// Determine docker socket path
|
|
|
|
|
let docker_socket = settings.docker_socket_path
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
.unwrap_or_else(|| default_docker_socket());
|
|
|
|
|
|
|
|
|
|
// AWS config path from global settings
|
|
|
|
|
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
// What we would create this container from *right now*: the project's
|
|
|
|
|
// snapshot when one exists, else the configured base. This is the value
|
|
|
|
|
// `container_needs_recreation` compares against the container's
|
|
|
|
|
// `triple-c.create-image` label — the check that replaced the old
|
|
|
|
|
// tautological one. It is resolved *before* the commit below, so it
|
|
|
|
|
// describes the pre-commit world the existing container was born into.
|
|
|
|
|
let snapshot_image = docker::get_snapshot_image_name(&project);
|
|
|
|
|
let expected_create_image =
|
|
|
|
|
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
|
|
|
|
snapshot_image.clone()
|
|
|
|
|
} else {
|
|
|
|
|
image_name.clone()
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
2026-03-03 16:16:19 -08:00
|
|
|
// Check if config changed — if so, snapshot + recreate
|
|
|
|
|
let needs_recreate = docker::container_needs_recreation(
|
|
|
|
|
&existing_id,
|
|
|
|
|
&project,
|
2026-08-09 18:19:12 -07:00
|
|
|
&expected_create_image,
|
2026-05-24 08:49:06 -07:00
|
|
|
&settings.global_aws,
|
|
|
|
|
&settings.global_ollama,
|
2026-08-09 16:55:28 -07:00
|
|
|
&settings.global_llamacpp,
|
2026-05-24 08:49:06 -07:00
|
|
|
&settings.global_openai_compatible,
|
2026-03-03 16:16:19 -08:00
|
|
|
settings.global_claude_instructions.as_deref(),
|
|
|
|
|
&settings.global_custom_env_vars,
|
|
|
|
|
settings.timezone.as_deref(),
|
2026-04-16 08:46:03 -07:00
|
|
|
settings.global_claude_code_settings.as_ref(),
|
|
|
|
|
settings.default_ssh_key_path.as_deref(),
|
2026-08-10 10:40:21 -07:00
|
|
|
settings.ca_cert_path.as_deref(),
|
2026-04-16 08:46:03 -07:00
|
|
|
settings.default_git_user_name.as_deref(),
|
|
|
|
|
settings.default_git_user_email.as_deref(),
|
2026-03-03 16:16:19 -08:00
|
|
|
).await.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
if needs_recreate {
|
|
|
|
|
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
|
|
|
|
// Snapshot the filesystem before destroying
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Saving container state...");
|
2026-03-03 16:16:19 -08:00
|
|
|
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
|
|
|
|
log::warn!("Failed to snapshot container before recreation: {}", e);
|
|
|
|
|
}
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Recreating container...");
|
2026-02-28 20:42:55 +00:00
|
|
|
let _ = docker::stop_container(&existing_id).await;
|
|
|
|
|
docker::remove_container(&existing_id).await?;
|
2026-03-03 16:16:19 -08:00
|
|
|
|
2026-08-09 10:31:18 -07:00
|
|
|
// Legacy MCP cleanup: the old container may have been attached to
|
|
|
|
|
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
|
|
|
|
// that network now, before the replacement is created without it.
|
|
|
|
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
|
|
|
|
docker::remove_legacy_project_network(&project.id).await;
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
// Create from snapshot image (preserves system-level changes).
|
|
|
|
|
// Re-resolved after the commit above: when no snapshot existed
|
|
|
|
|
// before, one does now, and creating from the base instead
|
|
|
|
|
// would throw away the state that was just saved.
|
2026-03-03 16:16:19 -08:00
|
|
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
2026-08-09 18:19:12 -07:00
|
|
|
snapshot_image.clone()
|
2026-03-03 16:16:19 -08:00
|
|
|
} else {
|
|
|
|
|
image_name.clone()
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
let new_id = create_container_for_project(
|
2026-02-28 20:42:55 +00:00
|
|
|
&project,
|
2026-08-09 18:19:12 -07:00
|
|
|
&settings,
|
2026-02-28 20:42:55 +00:00
|
|
|
&docker_socket,
|
|
|
|
|
aws_config_path.as_deref(),
|
2026-08-09 18:19:12 -07:00
|
|
|
&create_image,
|
|
|
|
|
&image_name,
|
|
|
|
|
docker::CreateExtras::default(),
|
2026-02-28 20:42:55 +00:00
|
|
|
).await?;
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
2026-02-28 20:42:55 +00:00
|
|
|
docker::start_container(&new_id).await?;
|
2026-08-11 19:01:20 -07:00
|
|
|
|
|
|
|
|
// The commit above moved `:latest` and orphaned the image it
|
|
|
|
|
// used to point at; the container holding that image open was
|
|
|
|
|
// removed a few lines up, so now is when Docker will actually
|
|
|
|
|
// let it go. Detached because this is housekeeping and the
|
|
|
|
|
// project is already running — and it sweeps every orphan, not
|
|
|
|
|
// just this one, so recreations that happened before the sweep
|
|
|
|
|
// existed are cleaned up too.
|
|
|
|
|
tauri::async_runtime::spawn(async {
|
2026-08-23 08:35:10 -07:00
|
|
|
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
2026-08-11 19:01:20 -07:00
|
|
|
});
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
new_id
|
|
|
|
|
} else {
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
2026-02-28 20:42:55 +00:00
|
|
|
docker::start_container(&existing_id).await?;
|
|
|
|
|
existing_id
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-03 16:16:19 -08:00
|
|
|
// Container doesn't exist (first start, or Docker pruned it).
|
|
|
|
|
// Check for a snapshot image first — it preserves system-level
|
|
|
|
|
// changes (apt/pip/npm installs) from the previous session.
|
2026-08-09 18:19:12 -07:00
|
|
|
if expected_create_image == snapshot_image {
|
2026-03-03 16:16:19 -08:00
|
|
|
log::info!("Creating container from snapshot image for project {}", project.id);
|
2026-08-09 18:19:12 -07:00
|
|
|
}
|
|
|
|
|
let create_image = expected_create_image.clone();
|
2026-03-03 16:16:19 -08:00
|
|
|
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Creating container...");
|
2026-08-09 18:19:12 -07:00
|
|
|
let new_id = create_container_for_project(
|
2026-02-27 09:56:39 -08:00
|
|
|
&project,
|
2026-08-09 18:19:12 -07:00
|
|
|
&settings,
|
2026-02-27 09:56:39 -08:00
|
|
|
&docker_socket,
|
|
|
|
|
aws_config_path.as_deref(),
|
2026-08-09 18:19:12 -07:00
|
|
|
&create_image,
|
|
|
|
|
&image_name,
|
|
|
|
|
docker::CreateExtras::default(),
|
2026-02-27 09:56:39 -08:00
|
|
|
).await?;
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
2026-02-27 09:56:39 -08:00
|
|
|
docker::start_container(&new_id).await?;
|
|
|
|
|
new_id
|
2026-02-28 20:42:55 +00:00
|
|
|
};
|
|
|
|
|
|
2026-06-30 14:37:33 -07:00
|
|
|
// Sync Bedrock credentials on every start: refresh static/session creds
|
|
|
|
|
// so rotated keys are picked up without a full container recreation, and
|
|
|
|
|
// clear stale creds when the project no longer uses static-cred Bedrock.
|
|
|
|
|
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
|
|
|
|
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
2026-06-30 13:34:43 -07:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
Ok(container_id)
|
|
|
|
|
}.await;
|
|
|
|
|
|
|
|
|
|
// On failure, reset status to Stopped so the project doesn't get stuck.
|
|
|
|
|
if let Err(ref e) = result {
|
|
|
|
|
log::error!("Failed to start container for project {}: {}", project_id, e);
|
|
|
|
|
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let container_id = result?;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
2026-02-27 04:29:51 +00:00
|
|
|
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
|
|
|
|
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
// Arm the auth bridge if this project opted in. Purely host-side, so it
|
|
|
|
|
// happens after the container is up and never affects the start itself.
|
|
|
|
|
if project.auth_bridge_enabled {
|
|
|
|
|
state
|
|
|
|
|
.auth_bridge
|
|
|
|
|
.start(
|
|
|
|
|
project_id.clone(),
|
|
|
|
|
container_id.clone(),
|
|
|
|
|
app_handle.clone(),
|
|
|
|
|
state.projects_store.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
project.container_id = Some(container_id);
|
|
|
|
|
project.status = ProjectStatus::Running;
|
|
|
|
|
Ok(project)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn stop_project_container(
|
|
|
|
|
project_id: String,
|
2026-03-04 07:43:01 -08:00
|
|
|
app_handle: tauri::AppHandle,
|
2026-02-27 04:29:51 +00:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<(), String> {
|
2026-08-23 11:45:28 -07:00
|
|
|
// Stop had **no** exclusion at all, which is the one gap CLAUDE.md's "every
|
|
|
|
|
// command that stops, removes or recreates the container consults
|
|
|
|
|
// `is_migrating`" rule already named and this function did not honour. A
|
|
|
|
|
// stop lands on the container a migration is mid-swap on, and it closes the
|
|
|
|
|
// exec sessions a compaction's config replay is not expecting to lose.
|
|
|
|
|
let _guard =
|
|
|
|
|
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Recreate)?;
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
let project = state
|
|
|
|
|
.projects_store
|
|
|
|
|
.get(&project_id)
|
|
|
|
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
// Drop host listeners first: they only make sense while the container runs.
|
|
|
|
|
state.auth_bridge.stop(&project_id).await;
|
|
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
if let Some(ref container_id) = project.container_id {
|
2026-02-27 04:29:51 +00:00
|
|
|
// Close exec sessions for this project
|
2026-03-04 07:43:01 -08:00
|
|
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
2026-02-27 19:54:44 +00:00
|
|
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
if let Err(e) = docker::stop_container(container_id).await {
|
|
|
|
|
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
2026-02-27 04:29:51 +00:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn rebuild_project_container(
|
|
|
|
|
project_id: String,
|
2026-03-04 07:43:01 -08:00
|
|
|
app_handle: tauri::AppHandle,
|
2026-02-27 04:29:51 +00:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Project, String> {
|
2026-08-09 19:35:39 -07:00
|
|
|
// Reset deletes both volumes and the snapshot image. Doing that while a
|
|
|
|
|
// migration is mid-flight pulls the ground out from under it and leaves an
|
2026-08-23 11:45:28 -07:00
|
|
|
// orphan migration record pointing at images that no longer exist — and
|
|
|
|
|
// doing it while a *compaction* is mid-flight is worse, because the
|
|
|
|
|
// compaction then commits `flat(old)` back over the `:latest` this just
|
|
|
|
|
// destroyed and resurrects the system layer the user asked to be rid of.
|
|
|
|
|
// Held for the whole Reset, including the start at the end of it.
|
|
|
|
|
let _guard =
|
|
|
|
|
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Reset)?;
|
2026-08-09 19:35:39 -07:00
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
let project = state
|
|
|
|
|
.projects_store
|
|
|
|
|
.get(&project_id)
|
|
|
|
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
// Reset supersedes any migration decision that was still pending: the
|
|
|
|
|
// snapshot image and both volumes are about to go, so a surviving record
|
|
|
|
|
// could only describe things that no longer exist — while its
|
|
|
|
|
// `:pre-migration-<ts>` tag held a whole snapshot image (multiple GB) alive
|
|
|
|
|
// with nothing left that could ever use it.
|
|
|
|
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
// The bridge is bound to the container that is about to be destroyed;
|
|
|
|
|
// `start_project_container` below re-arms it against the new one.
|
|
|
|
|
state.auth_bridge.stop(&project_id).await;
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
// Remove existing container
|
|
|
|
|
if let Some(ref container_id) = project.container_id {
|
2026-02-27 19:54:44 +00:00
|
|
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
2026-02-27 04:29:51 +00:00
|
|
|
let _ = docker::stop_container(container_id).await;
|
|
|
|
|
docker::remove_container(container_id).await?;
|
|
|
|
|
state.projects_store.set_container_id(&project_id, None)?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 16:16:19 -08:00
|
|
|
// Remove snapshot image + volumes so Reset creates from the clean base image
|
|
|
|
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
|
|
|
|
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = docker::remove_project_volumes(&project).await {
|
|
|
|
|
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 11:45:28 -07:00
|
|
|
// Start fresh. The locked variant, because `_guard` above is this project's
|
|
|
|
|
// claim and the public command would be refused by it.
|
|
|
|
|
start_project_container_locked(project_id, app_handle, state).await
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 08:29:06 -07:00
|
|
|
/// Reconcile project statuses against actual Docker container state.
|
|
|
|
|
/// Called by the frontend after Docker is confirmed available. Projects
|
|
|
|
|
/// marked as Running whose containers are no longer running get reset
|
|
|
|
|
/// to Stopped.
|
2026-08-09 18:19:12 -07:00
|
|
|
///
|
|
|
|
|
/// This is also where an interrupted **base-image migration** is picked up.
|
|
|
|
|
/// It runs at startup, which is exactly when a migration that died with the app
|
|
|
|
|
/// needs to be noticed — see
|
|
|
|
|
/// [`crate::commands::migration_commands::reconcile_migration`]. The migration
|
|
|
|
|
/// pass runs over *every* project, not just the Running ones, because a project
|
|
|
|
|
/// whose container was removed mid-migration reports Stopped.
|
2026-03-10 08:29:06 -07:00
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn reconcile_project_statuses(
|
2026-08-09 11:35:42 -07:00
|
|
|
app_handle: tauri::AppHandle,
|
2026-03-10 08:29:06 -07:00
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<Vec<Project>, String> {
|
|
|
|
|
let projects = state.projects_store.list();
|
|
|
|
|
|
2026-08-09 18:19:12 -07:00
|
|
|
for project in &projects {
|
|
|
|
|
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 08:29:06 -07:00
|
|
|
for project in &projects {
|
2026-08-09 19:35:39 -07:00
|
|
|
// `Starting` and `Stopping` are in here as a backstop, not because
|
|
|
|
|
// anything is expected to leave a project in one. They are transitional
|
|
|
|
|
// states owned by an in-flight command, so a project still wearing one
|
|
|
|
|
// is a project whose command died — a crash mid-start, or a migration
|
|
|
|
|
// that bailed out between the stop and the swap. Skipping them, as this
|
|
|
|
|
// loop used to, meant nothing in the app ever put such a project right:
|
|
|
|
|
// it sat at "Stopping" with the Start button disabled, permanently.
|
|
|
|
|
// Docker is the authority either way, so the check below is correct for
|
|
|
|
|
// all four.
|
|
|
|
|
if !matches!(
|
|
|
|
|
project.status,
|
|
|
|
|
ProjectStatus::Running
|
|
|
|
|
| ProjectStatus::Error
|
|
|
|
|
| ProjectStatus::Starting
|
|
|
|
|
| ProjectStatus::Stopping
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 11:45:28 -07:00
|
|
|
// ...but never for a project this process is actively working on. A
|
|
|
|
|
// migration's container is legitimately absent between the
|
|
|
|
|
// `remove_container` and the create that follows, and so is a Reset's,
|
|
|
|
|
// and so is a start's before its create returns. Reconciling into any
|
|
|
|
|
// of those windows writes `Stopped` over a project that is mid-run.
|
|
|
|
|
// Broadened from `is_migrating` to the whole lock for exactly that
|
|
|
|
|
// reason — the migration was never the only operation with a gap.
|
|
|
|
|
if crate::project_lock::held(&project.id).is_some() {
|
2026-03-10 08:29:06 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let is_running = if let Some(ref container_id) = project.container_id {
|
|
|
|
|
docker::is_container_running(container_id).await.unwrap_or(false)
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if is_running {
|
|
|
|
|
log::info!(
|
|
|
|
|
"Project '{}' ({}) container is still running — keeping Running status",
|
|
|
|
|
project.name,
|
|
|
|
|
project.id
|
|
|
|
|
);
|
2026-08-09 11:35:42 -07:00
|
|
|
// The app may have restarted while the container kept running; the
|
|
|
|
|
// bridge lives in this process, so re-arm it here. `start` is
|
|
|
|
|
// idempotent, so a bridge that is already polling is untouched.
|
|
|
|
|
if project.auth_bridge_enabled {
|
|
|
|
|
if let Some(ref container_id) = project.container_id {
|
|
|
|
|
state
|
|
|
|
|
.auth_bridge
|
|
|
|
|
.start(
|
|
|
|
|
project.id.clone(),
|
|
|
|
|
container_id.clone(),
|
|
|
|
|
app_handle.clone(),
|
|
|
|
|
state.projects_store.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-10 08:29:06 -07:00
|
|
|
} else {
|
|
|
|
|
log::info!(
|
|
|
|
|
"Project '{}' ({}) container is not running — setting to Stopped",
|
|
|
|
|
project.name,
|
|
|
|
|
project.id
|
|
|
|
|
);
|
|
|
|
|
let _ = state.projects_store.update_status(&project.id, ProjectStatus::Stopped);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(state.projects_store.list())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
fn default_docker_socket() -> String {
|
|
|
|
|
if cfg!(target_os = "windows") {
|
|
|
|
|
"//./pipe/docker_engine".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
"/var/run/docker.sock".to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 13:11:01 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn path(host: &str, mount: &str) -> ProjectPath {
|
|
|
|
|
ProjectPath {
|
|
|
|
|
host_path: host.to_string(),
|
|
|
|
|
mount_name: mount.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The mount name that reaches the scrub. `docker::create_container` builds
|
|
|
|
|
/// `/workspace/{mount_name}`, and the daemon normalises `..` out of it —
|
|
|
|
|
/// verified against Engine 29.7, where a mount created with a target of
|
|
|
|
|
/// `/workspace/../tmp/claude-x` is reported by `inspect` as `/tmp/claude-x`.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_mount_name_cannot_walk_out_of_workspace() {
|
|
|
|
|
for escape in ["..", "../tmp/claude-x", "../../etc", "/tmp/claude-x", "a/../.."] {
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths(&[path("/home/u/project", escape)]).is_err(),
|
|
|
|
|
"mount name '{}' was accepted, which puts the host folder somewhere \
|
|
|
|
|
/workspace/{{name}} does not reach",
|
|
|
|
|
escape
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// The dotted names that are *not* a traversal stay usable.
|
|
|
|
|
for ok in ["my.project", ".hidden", "a.b-c_d", "workspace2"] {
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths(&[path("/home/u/project", ok)]).is_ok(),
|
|
|
|
|
"mount name '{}' should be usable",
|
|
|
|
|
ok
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_whole_host_filesystem_cannot_be_mounted() {
|
|
|
|
|
// A read-write bind of `/` into a container whose agent has
|
|
|
|
|
// passwordless sudo.
|
|
|
|
|
for root in ["/", "//", "\\", "C:\\", "c:/", "D:"] {
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths(&[path(root, "everything")]).is_err(),
|
|
|
|
|
"host path '{}' was accepted as a project folder",
|
|
|
|
|
root
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert!(validate_project_paths(&[path("/home/u/project", "project")]).is_ok());
|
|
|
|
|
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
|
|
|
|
|
assert!(validate_project_paths(&[
|
|
|
|
|
path("/home/u/a", "same"),
|
|
|
|
|
path("/home/u/b", "same"),
|
|
|
|
|
])
|
|
|
|
|
.is_err());
|
|
|
|
|
assert!(validate_project_paths(&[path("/home/u/a", "")]).is_err());
|
|
|
|
|
assert!(validate_project_paths(&[path("", "a")]).is_err());
|
|
|
|
|
// "+ Add folder" inserts this and the next blur saves the whole list;
|
|
|
|
|
// refusing it would turn an empty row into an error toast.
|
|
|
|
|
assert!(validate_project_paths(&[path("/home/u/a", "a"), path("", "")]).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:12:48 -07:00
|
|
|
/// The distinction the whole secret-clearing path rests on.
|
|
|
|
|
///
|
|
|
|
|
/// Every secret field is `#[serde(skip_serializing)]`, so the project
|
|
|
|
|
/// object the frontend holds has no `git_token` key — a save from the
|
|
|
|
|
/// Workspace or Runtime section sends it *absent*, and clearing on absent
|
|
|
|
|
/// would delete the token every time an unrelated setting was changed. The
|
|
|
|
|
/// editor that owns the field sends an explicit `null`.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_blanked_secret_is_cleared_and_an_absent_one_is_left_alone() {
|
|
|
|
|
let blanked = serde_json::json!({
|
|
|
|
|
"id": "p1",
|
|
|
|
|
"git_token": null,
|
|
|
|
|
"bedrock_config": { "aws_bearer_token": null },
|
|
|
|
|
});
|
|
|
|
|
let cleared = explicitly_cleared_secrets(&blanked);
|
|
|
|
|
assert!(cleared.contains(&"git-token"));
|
|
|
|
|
assert!(cleared.contains(&"aws-bearer-token"));
|
|
|
|
|
// Present in the same payload, absent from the clear list.
|
|
|
|
|
assert!(!cleared.contains(&"aws-access-key-id"));
|
|
|
|
|
|
|
|
|
|
// What a Workspace-section save looks like: no secret keys at all.
|
|
|
|
|
let unrelated = serde_json::json!({ "id": "p1", "name": "renamed" });
|
|
|
|
|
assert!(explicitly_cleared_secrets(&unrelated).is_empty());
|
|
|
|
|
|
|
|
|
|
// And a value is neither.
|
|
|
|
|
let written = serde_json::json!({ "id": "p1", "git_token": "ghp_xxx" });
|
|
|
|
|
assert!(explicitly_cleared_secrets(&written).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_secret_field_can_be_cleared() {
|
|
|
|
|
// A field the frontend can blank but this list does not name is a
|
|
|
|
|
// credential that cannot be revoked through the UI, which is the bug
|
|
|
|
|
// this exists to close. The keychain keys must match the ones
|
|
|
|
|
// `load_secrets_for_project` reads back, or a save writes one name and
|
|
|
|
|
// the container is handed another.
|
|
|
|
|
for (pointer, key) in PROJECT_SECRET_FIELDS {
|
|
|
|
|
let field = pointer.rsplit('/').next().unwrap();
|
|
|
|
|
let payload = match pointer.matches('/').count() {
|
|
|
|
|
1 => serde_json::json!({ field: null }),
|
|
|
|
|
_ => {
|
|
|
|
|
let parent = pointer.trim_start_matches('/').split('/').next().unwrap();
|
|
|
|
|
serde_json::json!({ parent: { field: null } })
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
explicitly_cleared_secrets(&payload).contains(key),
|
|
|
|
|
"{} does not reach the keychain key {}",
|
|
|
|
|
pointer,
|
|
|
|
|
key
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:11:01 -07:00
|
|
|
#[test]
|
|
|
|
|
fn add_and_update_cannot_disagree_about_what_a_folder_list_may_contain() {
|
|
|
|
|
// `update_project` used to validate nothing at all, so every rule in
|
2026-08-23 15:42:20 -07:00
|
|
|
// `add_project` was one save-on-blur away from being bypassed. It now
|
|
|
|
|
// validates against what is stored rather than in isolation, but a row
|
|
|
|
|
// it has never seen before is held to exactly the same rules — this
|
|
|
|
|
// fails if either side grows its own copy.
|
2026-08-23 13:11:01 -07:00
|
|
|
let bad = [path("/home/u/project", "../tmp/claude-x")];
|
|
|
|
|
assert!(validate_project_paths(&bad).is_err());
|
2026-08-23 15:42:20 -07:00
|
|
|
assert!(validate_project_paths_update(&[], &bad).is_err());
|
|
|
|
|
let good = [path("/home/u/project", "project")];
|
|
|
|
|
assert!(validate_project_paths(&good).is_ok());
|
|
|
|
|
assert!(validate_project_paths_update(&[], &good).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Folder lists that are already in `projects.json` ──────────────────
|
|
|
|
|
//
|
|
|
|
|
// `update_project` validated nothing while `WorkspaceSection` saved
|
|
|
|
|
// `{paths}` on every blur, so a stored list can break rules that only
|
|
|
|
|
// `add_project` ever enforced. Holding a save to all of them turned every
|
|
|
|
|
// such project into one that cannot be saved *at all* — not its folders:
|
|
|
|
|
// `update_project` is the single command behind the whole Config tab, so a
|
|
|
|
|
// sandbox toggle, a permission-mode change and `useTerminal.ts`'s tab
|
|
|
|
|
// rename all came back with a message about folders.
|
|
|
|
|
|
|
|
|
|
/// The shapes a real `projects.json` can be holding. None of them is an
|
|
|
|
|
/// escape from `/workspace`; all of them used to brick the editor.
|
|
|
|
|
fn legacy_rows() -> Vec<Vec<ProjectPath>> {
|
|
|
|
|
vec![
|
|
|
|
|
// Half-filled: "+ Add folder", a host path typed, no name yet, and
|
|
|
|
|
// an unrelated blur saved the list.
|
|
|
|
|
vec![path("/home/u/a", "a"), path("/home/u/b", "")],
|
|
|
|
|
vec![path("/home/u/a", "a"), path("", "b")],
|
|
|
|
|
// A mount name the character check refuses but the daemon puts
|
|
|
|
|
// exactly where it says: /workspace/my project.
|
|
|
|
|
vec![path("/home/u/a", "my project")],
|
|
|
|
|
vec![path("/home/u/a", "web@2")],
|
|
|
|
|
// Two rows sharing a name.
|
|
|
|
|
vec![path("/home/u/a", "same"), path("/home/u/b", "same")],
|
|
|
|
|
// The whole drive, from before anything refused it.
|
|
|
|
|
vec![path("/", "everything")],
|
|
|
|
|
vec![path("C:\\", "everything")],
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_project_stored_with_a_bad_row_can_still_be_saved() {
|
|
|
|
|
for rows in legacy_rows() {
|
|
|
|
|
// The full rule set is what made these unsavable…
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths(&rows).is_err(),
|
|
|
|
|
"fixture {:?} is not actually a rule violation",
|
|
|
|
|
rows
|
|
|
|
|
);
|
|
|
|
|
// …and an unrelated Config save re-sends the list it was given.
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths_update(&rows, &rows).is_ok(),
|
|
|
|
|
"saving an unrelated setting on a project stored as {:?} is refused, so every \
|
|
|
|
|
toggle in the Config tab fails with a message about folders",
|
|
|
|
|
rows
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_same_bad_row_is_refused_when_it_is_new() {
|
|
|
|
|
let stored = [path("/home/u/project", "project")];
|
|
|
|
|
for rows in legacy_rows() {
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths_update(&stored, &rows).is_err(),
|
|
|
|
|
"{:?} was introduced through update_project, which is the escalation the \
|
|
|
|
|
validation exists to stop",
|
|
|
|
|
rows
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The one rule no exemption reaches. `/workspace/../tmp/claude-x`
|
|
|
|
|
/// normalises to `/tmp/claude-x` — a path the pre-commit scrub owns and
|
|
|
|
|
/// empties as root — so a stored one is a live data-loss chain rather than
|
|
|
|
|
/// untidy data, and the Workspace section is one edit away.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_mount_that_leaves_workspace_is_refused_however_it_got_there() {
|
|
|
|
|
for escape in ["..", "../tmp/claude-x", "../../etc", "/tmp/claude-x", "a/../..", ".", "..\\x"] {
|
|
|
|
|
let rows = [path("/home/u/project", escape)];
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths_update(&rows, &rows).is_err(),
|
|
|
|
|
"mount name '{}' was grandfathered, so the C1 chain stays open for anyone who \
|
|
|
|
|
already has it stored",
|
|
|
|
|
escape
|
|
|
|
|
);
|
|
|
|
|
assert!(validate_project_paths_update(&[], &rows).is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn editing_a_grandfathered_row_holds_it_to_every_rule_again() {
|
|
|
|
|
let stored = [path("/", "everything")];
|
|
|
|
|
// Renaming the mount but keeping the root host path is a new row.
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths_update(&stored, &[path("/", "all")]).is_err(),
|
|
|
|
|
"an edited row was admitted on the strength of the row it replaced"
|
|
|
|
|
);
|
|
|
|
|
// Fixing the host path is what the message asks for, and it saves.
|
|
|
|
|
assert!(
|
|
|
|
|
validate_project_paths_update(&stored, &[path("/home/u/a", "everything")]).is_ok()
|
|
|
|
|
);
|
|
|
|
|
// Dropping the row entirely is always fine.
|
|
|
|
|
assert!(validate_project_paths_update(&stored, &[]).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_stored_duplicate_may_be_kept_but_not_multiplied() {
|
|
|
|
|
let stored = [path("/home/u/a", "same"), path("/home/u/b", "same")];
|
|
|
|
|
assert!(validate_project_paths_update(&stored, &stored).is_ok());
|
|
|
|
|
// Order must not change the answer.
|
|
|
|
|
let reordered = [stored[1].clone(), stored[0].clone()];
|
|
|
|
|
assert!(validate_project_paths_update(&stored, &reordered).is_ok());
|
|
|
|
|
// A third row taking the same name is new, and refused.
|
|
|
|
|
let more = [
|
|
|
|
|
stored[0].clone(),
|
|
|
|
|
stored[1].clone(),
|
|
|
|
|
path("/home/u/c", "same"),
|
|
|
|
|
];
|
|
|
|
|
assert!(validate_project_paths_update(&stored, &more).is_err());
|
|
|
|
|
// And a second copy of a name that was unique stays refused.
|
|
|
|
|
let unique = [path("/home/u/a", "a")];
|
|
|
|
|
assert!(validate_project_paths_update(
|
|
|
|
|
&unique,
|
|
|
|
|
&[path("/home/u/a", "a"), path("/home/u/b", "a")]
|
|
|
|
|
)
|
|
|
|
|
.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_blank_placeholder_row_is_still_not_an_error_on_either_path() {
|
|
|
|
|
let stored = [path("/home/u/a", "a")];
|
|
|
|
|
let with_placeholder = [path("/home/u/a", "a"), path("", "")];
|
|
|
|
|
assert!(validate_project_paths(&with_placeholder).is_ok());
|
|
|
|
|
assert!(validate_project_paths_update(&stored, &with_placeholder).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── The two host paths that had no check at all ───────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_filesystem_root_cannot_be_newly_set_as_an_ssh_or_ca_path() {
|
|
|
|
|
for root in ["/", "//", "\\", "C:\\", "c:/", "D:"] {
|
|
|
|
|
assert!(
|
|
|
|
|
validate_mounted_host_path("the SSH key folder", None, Some(root)).is_err(),
|
|
|
|
|
"'{}' was accepted as an SSH key path, which read-only bind-mounts the whole \
|
|
|
|
|
host filesystem at /tmp/.host-ssh",
|
|
|
|
|
root
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
validate_mounted_host_path("the CA certificate path", Some("/etc/ssl"), Some(root))
|
|
|
|
|
.is_err(),
|
|
|
|
|
"'{}' was accepted as a CA certificate path",
|
|
|
|
|
root
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// A real folder, a cleared value and an absent one are all fine.
|
|
|
|
|
assert!(validate_mounted_host_path("x", None, Some("/home/u/.ssh")).is_ok());
|
|
|
|
|
assert!(validate_mounted_host_path("x", Some("/home/u/.ssh"), None).is_ok());
|
|
|
|
|
assert!(validate_mounted_host_path("x", Some("/home/u/.ssh"), Some("")).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_ssh_path_already_stored_does_not_brick_the_editor_either() {
|
|
|
|
|
// Nothing ever validated this field, so it can hold a root today — and
|
|
|
|
|
// it is mounted on every container start whether or not an unrelated
|
|
|
|
|
// Config save is allowed through.
|
|
|
|
|
assert!(validate_mounted_host_path("x", Some("/"), Some("/")).is_ok());
|
|
|
|
|
assert!(validate_mounted_host_path("x", Some("/"), Some(" / ")).is_ok());
|
|
|
|
|
// Changing it to a different root is a change, and refused.
|
|
|
|
|
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
|
2026-08-23 13:11:01 -07:00
|
|
|
}
|
|
|
|
|
}
|