Skip to content

coast assign --worktree <absolute path> silently destroys /workspace in the instance #262

Description

@iamfinky

coast assign --worktree <absolute path> destroys /workspace in the instance

Version: 0.1.53 (still present at a62f512)
Runtime: local dind, Linux

Passing an absolute path to -w/--worktree silently unmounts the instance's
/workspace and leaves nothing in its place. Passing the bare worktree name
works fine. The command reports ok for the worktree step either way.

Repro

coast run lane1
coast assign lane1 -w /abs/path/to/repo/.worktrees/my-worktree     #
coast exec lane1 -- grep -c " /workspace " /proc/self/mountinfo    # 0
coast exec lane1 -- sh -c 'ls /workspace | wc -l'                  # 0

coast unassign lane1
coast assign lane1 -w my-worktree                                  # ✅ same worktree, name only
coast exec lane1 -- grep -c " /workspace " /proc/self/mountinfo    # 1
coast exec lane1 -- sh -c 'ls /workspace | wc -l'                  # 49

Downstream, services can't start — mounting the file frankenphp/Caddyfile
onto the stub directory Docker auto-creates under the empty /workspace:

error mounting "/workspace/frankenphp/Caddyfile" to rootfs at "/etc/frankenphp/Caddyfile":
not a directory

coast doctor reports "Everything looks good. No orphaned state found."

Cause 1 — the gate and the mount source are built differently

coast-daemon/src/handlers/assign/services.rs:361-363:

let candidate = project_root.join(dir).join(worktree_name);
if candidate.exists() {
    let mount_src = format!("/host-project/{dir}/{worktree_name}");

Path::join replaces the base when its argument is absolute; format! just
concatenates. With an absolute --worktree:

candidate       = /abs/path/to/repo/.worktrees/my-worktree        → exists() == true, gate passes
mount_src       = /host-project/.worktrees//abs/path/to/repo/.worktrees/my-worktree   → does not exist

coast-cli/src/commands/assign.rs:43-67 passes --worktree through verbatim
with no is_absolute() guard. All five detection sites share the pattern
(services.rs:320-321, :341, :361, :577, :864), and lib.rs:1815-1823
filters on host_path.exists() (true) before binding container_mount_src
(garbage), so no fallback catches it.

Cause 2 — the remount is not fail-safe, so a bad source loses the good mount

services.rs:1166-1170:

let mount_cmd = format!(
    "{unmount_cache}{unmount_private}{clear_private}umount -l /workspace 2>/dev/null; mount --bind {mount_src} /workspace && \
     mount --make-rshared /workspace && \
     mkdir -p '{parent}' && ln -sfn /host-project '{host_root}'{private_cmds}{cache_cmds}"
);

The umount -l is unconditional and chained with ;; everything after is &&.
So a failing bind leaves /workspace with zero mounts rather than the
previous one. That's what turns a bad argument into a destroyed instance.

Cause 3 — the failure never surfaces

ensure_worktree_exists (services.rs:903-978) validates the correct host
path and returns Ok, so the step prints ok. The remount failure is only
warned (services.rs:638-645) and the return value is discarded at
services.rs:890-897. The assign then fails later, opaquely, at the health check.

Why --force-sync doesn't repair it

It only clears an ignored-file marker and never touches mounts
(services.rs:1065-1069), then recomputes the identical bad mount_src.

Why unassign does repair it

unassign.rs:301-303 hardcodes mount --bind /host-project /workspace — no
detection, no concatenation, and /host-project always exists.

Suggested fixes

  1. Fail safe. Verify the source exists in-container before unmounting, and
    fall back to the previous bind if the new one fails.
  2. Derive mount_src from the validated host path, not the raw argument, at
    all five sites — invariant:
    container_mount_src == "/host-project/" + host_path.strip_prefix(project_root).
    Anything not expressible that way is unreachable and should be a hard error.
  3. Propagate the remount result — make remount_workspace return Result and
    check it at services.rs:890. A failed /workspace remount must fail the
    assign rather than print ok.
  4. Reject or canonicalize a path-shaped --worktree at the CLI boundary —
    the cheapest guard, and it catches the reported invocation outright.

Related: instances stuck in unassigning forever

Two compounding issues:

  • unassign.rs:510-522 writes InstanceStatus::Unassigning before the
    container_id … .ok_or_else(…)?, so that ? strands the row.
    assign/mod.rs gets this ordering right (check :103, write :108).
  • Unassigning is absorbing: validate_unassignable accepts it, prev_status
    is read pre-write, and the success path only special-cases Idle
    (unassign.rs:570-574) — so a successful retry writes unassigning back.
    No startup reconciliation covers transitional statuses.

The fix already exists in assign/mod.rs:373-376:

let final_status = match instance_status {
    InstanceStatus::Assigning | InstanceStatus::Unassigning => InstanceStatus::Running,
    other => other.clone(),
};

Applying that shape at unassign.rs:570 would let a stuck instance self-heal on
the next unassign.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions