fix(sandbox): clone Daytona repos under /home/daytona/repos (#285)

## Summary

Daytona's default snapshot runs as the `daytona` user (uid 1001), which
lacks write permission on `/`. With `run.clone.enabled = true`, sandbox
init failed at `fs.create_folder("/repos", ...)` with HTTP 400, before
the first workflow stage could run:

```
sandbox.git.failed error="Failed to create Daytona repos root" causes=["HTTP 400"]
run.failed
```

Root cause: the Daytona provider was using Docker's root-level `/repos`
layout. Docker works because its containers run as root; Daytona's
default sandbox user does not.

**Fix:** move `REPOS_ROOT` for Daytona to `/home/daytona/repos`,
alongside the existing `/home/daytona/workspace`. The path is writable
by the default sandbox user, the symlink layout is unchanged
(`/home/daytona/workspace/<repo>` →
`/home/daytona/repos/<owner>/<repo>`),
and Docker keeps its existing `/repos` path.

**Bonus — better error diagnostics.** A new `wrap_fs_error(operation,
path, error)` helper in the Daytona provider:

- includes the attempted path in the message (was just "Failed to create
  Daytona repos root" with no indication of which path);
- classifies HTTP 400 as a likely permission issue and points at
  snapshot configuration;
- classifies HTTP 401/403 as an API key permissions issue;
- preserves the underlying `DaytonaError` in the source chain
  (per `docs/internal/error-handling-strategy.md` — verified by walking
  `Error::source()` in the regression test).

So if this class of failure recurs (custom snapshot, future path
changes, ...) the user gets:

> Failed to create Daytona repos root '/home/daytona/repos' failed
> (HTTP 400). This usually means the sandbox user lacks write permission
> on the parent directory. If you're using a custom Daytona snapshot,
> ensure the sandbox user can write to '/home/daytona/repos', or use a
> path under the user's home directory (e.g. /home/daytona/...).

instead of:

> Failed to create Daytona repos root
> HTTP 400

## Test plan

- [x] `cargo build --workspace`
- [x] `cargo nextest run -p fabro-sandbox --features daytona` — 142/142
pass
- [x] `cargo nextest run -p fabro-types -p fabro-workflow` — 1365/1365
pass
- [x] New unit test `wrap_fs_error_classifies_http_400_and_403` —
asserts
      top-level message contains path + hint AND walks the source chain
      to prove `DaytonaError::Api { status_code: 400, .. }` is preserved
- [x] `cargo +nightly-2026-04-14 fmt --check --all`
- [x] `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- [x] **Live regression**: `daytona_clone_layout_live_smoke` against the
      default `daytona-medium` snapshot — failed with `Failed to create
      Daytona repos root / HTTP 400` before the change; passes
      end-to-end after (provisions sandbox → clones repo → verifies
      symlink + HEAD match in 2.5s)

## Related

- Closes #284 (thanks @jessmartin for the report, diagnosis, and
proposed fix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Jess Martin <27258+jessmartin@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-16 10:47:54 -07:00 • committed by GitHub
parent 64fd4de393
commit ae55bded81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 95 additions and 12 deletions

View file

@ -33,7 +33,7 @@ use crate::{
};
pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
pub(crate) const REPOS_ROOT: &str = "/repos";
pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos";
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str =
@ -814,8 +814,11 @@ impl Sandbox for DaytonaSandbox {
.create_folder(WORKING_DIRECTORY, None)
.await
.map_err(|e| {
let err =
crate::Error::context("Failed to create Daytona workspace root", e);
let err = wrap_fs_error(
"Failed to create Daytona workspace root",
WORKING_DIRECTORY,
e,
);
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url.clone(),
error: err.to_string(),
@ -824,7 +827,7 @@ impl Sandbox for DaytonaSandbox {
self.fail_init(init_start, err)
})?;
fs_svc.create_folder(REPOS_ROOT, None).await.map_err(|e| {
let err = crate::Error::context("Failed to create Daytona repos root", e);
let err = wrap_fs_error("Failed to create Daytona repos root", REPOS_ROOT, e);
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url.clone(),
error: err.to_string(),
@ -836,8 +839,9 @@ impl Sandbox for DaytonaSandbox {
.create_folder(&layout.repos_owner_path, None)
.await
.map_err(|e| {
let err = crate::Error::context(
let err = wrap_fs_error(
"Failed to create Daytona repos owner directory",
&layout.repos_owner_path,
e,
);
self.emit(SandboxEvent::GitCloneFailed {
@ -1790,6 +1794,27 @@ fn daytona_callback_error(err: &crate::Error) -> DaytonaError {
DaytonaError::general(format!("output callback failed: {err}"))
}
/// Wrap a Daytona filesystem error with a richer message that includes the
/// attempted path and a hint when the status code suggests a configuration
/// issue. Preserves the underlying `DaytonaError` in the source chain so
/// callers can still inspect status/headers via downcasting.
fn wrap_fs_error(operation: &str, path: &str, error: DaytonaError) -> crate::Error {
let message = match error.status_code() {
Some(400) => format!(
"{operation} '{path}' failed (HTTP 400). This usually means the sandbox user \
lacks write permission on the parent directory. If you're using a custom \
Daytona snapshot, ensure the sandbox user can write to '{path}', or use a \
path under the user's home directory (e.g. /home/daytona/...)."
),
Some(status @ (401 | 403)) => format!(
"{operation} '{path}' rejected by Daytona (HTTP {status}) — check that your \
DAYTONA_API_KEY has the required permissions."
),
_ => format!("{operation} '{path}' failed"),
};
crate::Error::context(message, error)
}
async fn finish_daytona_log_stream(
stream_task: &mut JoinHandle<Result<(), DaytonaError>>,
) -> crate::Result<bool> {
@ -2127,6 +2152,7 @@ fn wrap_bash_command(command: &str) -> String {
#[cfg(test)]
mod tests {
use daytona_api_client::models::api_key_list::Permissions;
use fabro_util::error::collect_chain;
use httpmock::Method::GET;
use httpmock::MockServer;
@ -2274,10 +2300,64 @@ subpath = "agents"
assert_eq!(
daytona_symlink_command(&layout),
"ln -s /repos/fabro-sh/fabro /home/daytona/workspace/fabro"
"ln -s /home/daytona/repos/fabro-sh/fabro /home/daytona/workspace/fabro"
);
}
#[test]
fn wrap_fs_error_classifies_http_400_and_403() {
let err_400 = wrap_fs_error(
"Failed to create Daytona repos root",
"/home/daytona/repos",
DaytonaError::api(400, ""),
);
let top_400 = err_400.to_string();
assert!(
top_400.contains("/home/daytona/repos"),
"400 top-level message should include the attempted path, got: {top_400}"
);
assert!(
top_400.contains("HTTP 400") && top_400.contains("write permission"),
"400 top-level message should classify as a permission issue, got: {top_400}"
);
let chain_400 = collect_chain(&err_400);
assert!(
chain_400
.iter()
.skip(1)
.any(|cause| cause.contains("HTTP 400") || cause.is_empty()),
"400 source chain should preserve the underlying DaytonaError, got: {chain_400:?}"
);
let source_400 = std::error::Error::source(&err_400)
.and_then(|s| s.downcast_ref::<DaytonaError>())
.expect("source should be a DaytonaError");
assert_eq!(
source_400.status_code(),
Some(400),
"downcast source should preserve the original status code"
);
let err_403 = wrap_fs_error(
"Failed to create Daytona repos root",
"/home/daytona/repos",
DaytonaError::api(403, ""),
);
let top_403 = err_403.to_string();
assert!(
top_403.contains("/home/daytona/repos") && top_403.contains("HTTP 403"),
"403 top-level message should include the path and status, got: {top_403}"
);
assert!(
top_403.contains("DAYTONA_API_KEY"),
"403 top-level message should hint at API key permissions, got: {top_403}"
);
let source_403 = std::error::Error::source(&err_403)
.and_then(|s| s.downcast_ref::<DaytonaError>())
.expect("source should be a DaytonaError");
assert_eq!(source_403.status_code(), Some(403));
}
#[test]
fn missing_display_uses_daytona_wire_scope_names() {
let check = DaytonaKeyCheck {

View file

@ -82,10 +82,10 @@ mod daytona_streaming_live {
let result = sandbox
.exec_command(
"test -d /repos/brynary/rack-test/.git && \
"test -d /home/daytona/repos/brynary/rack-test/.git && \
test -L /home/daytona/workspace/rack-test && \
test \"$(readlink /home/daytona/workspace/rack-test)\" = /repos/brynary/rack-test && \
test \"$(git -C /repos/brynary/rack-test rev-parse HEAD)\" = \
test \"$(readlink /home/daytona/workspace/rack-test)\" = /home/daytona/repos/brynary/rack-test && \
test \"$(git -C /home/daytona/repos/brynary/rack-test rev-parse HEAD)\" = \
\"$(git -C /home/daytona/workspace/rack-test rev-parse HEAD)\" && \
git rev-parse --is-inside-work-tree",
30_000,

View file

@ -62,7 +62,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
clone_origin_url: None,
clone_branch: None,
workspace_root: Some("/home/daytona/workspace".to_string()),
repos_root: Some("/repos".to_string()),
repos_root: Some("/home/daytona/repos".to_string()),
primary_repo_path: None,
primary_repo_link: None,
}),
@ -99,7 +99,10 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
value["sandbox"]["runtime"]["workspace_root"],
"/home/daytona/workspace"
);
assert_eq!(value["sandbox"]["runtime"]["repos_root"], "/repos");
assert_eq!(
value["sandbox"]["runtime"]["repos_root"],
"/home/daytona/repos"
);
assert_eq!(
value["web_url"],
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9"

View file

@ -1868,7 +1868,7 @@ async fn daytona_cp_upload_download_round_trip() {
clone_origin_url: None,
clone_branch: None,
workspace_root: Some("/home/daytona/workspace".to_string()),
repos_root: Some("/repos".to_string()),
repos_root: Some("/home/daytona/repos".to_string()),
primary_repo_path: None,
primary_repo_link: None,
}),