mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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:
parent
64fd4de393
commit
ae55bded81
4 changed files with 95 additions and 12 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue