package cli import ( "context" "flag" "fmt" "os" "path" "text/tabwriter" "github.com/davis7dotsh/tx9/internal/box" "github.com/davis7dotsh/tx9/internal/docker") func cmdMount(args []string) error { if len(args) == 0 { return fmt.Errorf("mount: action required (usage: tx9 mount <add|list|remove> ...)") } switch args[0] { case "add": return cmdMountAdd(args[1:]) case "list": return cmdMountList(args[1:]) case "remove", "rm": return cmdMountRemove(args[1:]) default: return fmt.Errorf("mount: unknown action %q (usage: tx9 mount <add|list|remove> ...)", args[0]) }} func cmdMountAdd(args []string) error { fs := flag.NewFlagSet("mount add", flag.ContinueOnError) readOnly := fs.Bool("read-only", false, "mount read-only inside the agent container") requireMountpoint := fs.Bool("require-mountpoint", false, "refuse recreation unless the source is an active host mountpoint") if err := parseFlagsAnywhere(fs, args); err != nil { return err } if fs.NArg() != 3 { return fmt.Errorf("mount add: expected box, host source, and container target (usage: tx9 mount add <box> <source> <target>)") } name, source, target := fs.Arg(0), fs.Arg(1), fs.Arg(2) mount, err := box.NewAgentMount(source, target, *readOnly, *requireMountpoint) if err != nil { return fmt.Errorf("mount add %s: %w", name, err) } return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { b, err := box.Get(ctx, cli, name) if err != nil { return fmt.Errorf("mount add %s: %w", name, err) } mounts, err := box.LoadAgentMounts(name) if err != nil { return fmt.Errorf("mount add %s: %w", name, err) } for _, existing := range mounts { if existing.Target != mount.Target { continue } if existing == mount { // Idempotent no-op: recreating the agent here would kill // in-flight agent work for zero configuration change. fmt.Printf("tx9: mount already configured for %s; nothing to do\n", name) return nil } return fmt.Errorf("mount add %s: target %s is already configured from %s; remove it first", name, mount.Target, existing.Source) } mounts = append(mounts, mount) if err := applyAgentMounts(ctx, cli, b, name, mounts); err != nil { return fmt.Errorf("mount add %s: %w", name, err) } fmt.Printf("tx9: mounted %s at %s in %s (%s)\n", mount.Source, mount.Target, name, mountMode(mount)) return nil })} func cmdMountList(args []string) error { fs := flag.NewFlagSet("mount list", flag.ContinueOnError) if err := parseFlagsAnywhere(fs, args); err != nil { return err } name, err := requireBoxName(fs, "mount list") if err != nil { return err } return withDocker(func(ctx context.Context, cli *docker.Client) error { if _, err := box.Get(ctx, cli, name); err != nil { return fmt.Errorf("mount list %s: %w", name, err) } mounts, err := box.LoadAgentMounts(name) if err != nil { return fmt.Errorf("mount list %s: %w", name, err) } if len(mounts) == 0 { fmt.Printf("tx9: no agent mounts configured for %s\n", name) return nil } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(w, "SOURCE\tTARGET\tMODE\tREQUIRE MOUNTPOINT") for _, mount := range mounts { fmt.Fprintf(w, "%s\t%s\t%s\t%t\n", mount.Source, mount.Target, mountMode(mount), mount.RequireMountpoint) } return w.Flush() })} func cmdMountRemove(args []string) error { fs := flag.NewFlagSet("mount remove", flag.ContinueOnError) if err := parseFlagsAnywhere(fs, args); err != nil { return err } if fs.NArg() != 2 { return fmt.Errorf("mount remove: expected box and container target (usage: tx9 mount remove <box> <target>)") } name, target := fs.Arg(0), path.Clean(fs.Arg(1)) return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { b, err := box.Get(ctx, cli, name) if err != nil { return fmt.Errorf("mount remove %s: %w", name, err) } mounts, err := box.LoadAgentMounts(name) if err != nil { return fmt.Errorf("mount remove %s: %w", name, err) } kept := mounts[:0] found := false for _, mount := range mounts { if mount.Target == target { found = true continue } kept = append(kept, mount) } if !found { // Idempotent no-op (or a typo'd target): never recreate the // agent when the desired mount set did not change. fmt.Printf("tx9: no configured mount at %s; nothing to do\n", target) return nil } if err := applyAgentMounts(ctx, cli, b, name, kept); err != nil { return fmt.Errorf("mount remove %s: %w", name, err) } fmt.Printf("tx9: removed mount at %s from %s\n", target, name) return nil })} // applyAgentMounts recreates the agent with the desired mount set and only// then persists it, so state and container cannot diverge in a way a retry// of the same command would skip: a recreation failure leaves the old state// (the retry is not an idempotent no-op), and a persisted set always// describes a container that actually carried it.func applyAgentMounts(ctx context.Context, cli *docker.Client, b *box.Box, name string, mounts []box.AgentMount) error { token, err := box.Token(b.Name) if err != nil { return err } wasRunning, err := box.RecreateAgent(ctx, cli, b, token, mounts) if err != nil { return err } if err := box.SaveAgentMounts(name, mounts); err != nil { return fmt.Errorf("agent was recreated with the desired mounts but persisting them failed; re-run the command: %w", err) } if !wasRunning { return nil } // A quiesced box (e.g. freshly imported, single-writer safety gate) must // stay quiesced: PrepareRuntime runs `hb up`, which clears the quiesce // marker and wires MCP — exactly what cmd_import deliberately avoids. // An inconclusive check is an error, not "active": guessing wrong would // unquiesce the box. The mounts themselves are already applied and // saved; the box's own workload loop reconciles the runtime regardless. quiesced, err := box.AgentQuiesced(ctx, cli, b, token) if err != nil { return fmt.Errorf("mounts are applied, but could not determine whether the box is quiesced; skipped runtime preparation: %w", err) } if quiesced { fmt.Println("tx9: box is quiesced; skipping runtime preparation (run `hb resume` inside the box when ready)") return nil } fmt.Println("tx9: preparing recreated agent runtime") return box.PrepareRuntime(ctx, cli, b, token, os.Stdout)} func mountMode(mount box.AgentMount) string { if mount.ReadOnly { return "ro" } return "rw"}
package cli
import (
"context"
"flag"
"fmt"
"os"
"path"
"text/tabwriter"
"github.com/davis7dotsh/tx9/internal/box"
"github.com/davis7dotsh/tx9/internal/docker"
)
func cmdMount(args []string) error {
if len(args) == 0 {
return fmt.Errorf("mount: action required (usage: tx9 mount <add|list|remove> ...)")
}
switch args[0] {
case "add":
return cmdMountAdd(args[1:])
case "list":
return cmdMountList(args[1:])
case "remove", "rm":
return cmdMountRemove(args[1:])
default:
return fmt.Errorf("mount: unknown action %q (usage: tx9 mount <add|list|remove> ...)", args[0])
}
}
func cmdMountAdd(args []string) error {
fs := flag.NewFlagSet("mount add", flag.ContinueOnError)
readOnly := fs.Bool("read-only", false, "mount read-only inside the agent container")
requireMountpoint := fs.Bool("require-mountpoint", false, "refuse recreation unless the source is an active host mountpoint")
if err := parseFlagsAnywhere(fs, args); err != nil {
return err
}
if fs.NArg() != 3 {
return fmt.Errorf("mount add: expected box, host source, and container target (usage: tx9 mount add <box> <source> <target>)")
}
name, source, target := fs.Arg(0), fs.Arg(1), fs.Arg(2)
mount, err := box.NewAgentMount(source, target, *readOnly, *requireMountpoint)
if err != nil {
return fmt.Errorf("mount add %s: %w", name, err)
}
return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
b, err := box.Get(ctx, cli, name)
if err != nil {
return fmt.Errorf("mount add %s: %w", name, err)
}
mounts, err := box.LoadAgentMounts(name)
if err != nil {
return fmt.Errorf("mount add %s: %w", name, err)
}
for _, existing := range mounts {
if existing.Target != mount.Target {
continue
}
if existing == mount {
// Idempotent no-op: recreating the agent here would kill
// in-flight agent work for zero configuration change.
fmt.Printf("tx9: mount already configured for %s; nothing to do\n", name)
return nil
}
return fmt.Errorf("mount add %s: target %s is already configured from %s; remove it first", name, mount.Target, existing.Source)
}
mounts = append(mounts, mount)
if err := applyAgentMounts(ctx, cli, b, name, mounts); err != nil {
return fmt.Errorf("mount add %s: %w", name, err)
}
fmt.Printf("tx9: mounted %s at %s in %s (%s)\n", mount.Source, mount.Target, name, mountMode(mount))
return nil
})
}
func cmdMountList(args []string) error {
fs := flag.NewFlagSet("mount list", flag.ContinueOnError)
if err := parseFlagsAnywhere(fs, args); err != nil {
return err
}
name, err := requireBoxName(fs, "mount list")
if err != nil {
return err
}
return withDocker(func(ctx context.Context, cli *docker.Client) error {
if _, err := box.Get(ctx, cli, name); err != nil {
return fmt.Errorf("mount list %s: %w", name, err)
}
mounts, err := box.LoadAgentMounts(name)
if err != nil {
return fmt.Errorf("mount list %s: %w", name, err)
}
if len(mounts) == 0 {
fmt.Printf("tx9: no agent mounts configured for %s\n", name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "SOURCE\tTARGET\tMODE\tREQUIRE MOUNTPOINT")
for _, mount := range mounts {
fmt.Fprintf(w, "%s\t%s\t%s\t%t\n", mount.Source, mount.Target, mountMode(mount), mount.RequireMountpoint)
}
return w.Flush()
})
}
func cmdMountRemove(args []string) error {
fs := flag.NewFlagSet("mount remove", flag.ContinueOnError)
if err := parseFlagsAnywhere(fs, args); err != nil {
return err
}
if fs.NArg() != 2 {
return fmt.Errorf("mount remove: expected box and container target (usage: tx9 mount remove <box> <target>)")
}
name, target := fs.Arg(0), path.Clean(fs.Arg(1))
return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
b, err := box.Get(ctx, cli, name)
if err != nil {
return fmt.Errorf("mount remove %s: %w", name, err)
}
mounts, err := box.LoadAgentMounts(name)
if err != nil {
return fmt.Errorf("mount remove %s: %w", name, err)
}
kept := mounts[:0]
found := false
for _, mount := range mounts {
if mount.Target == target {
found = true
continue
}
kept = append(kept, mount)
}
if !found {
// Idempotent no-op (or a typo'd target): never recreate the
// agent when the desired mount set did not change.
fmt.Printf("tx9: no configured mount at %s; nothing to do\n", target)
return nil
}
if err := applyAgentMounts(ctx, cli, b, name, kept); err != nil {
return fmt.Errorf("mount remove %s: %w", name, err)
}
fmt.Printf("tx9: removed mount at %s from %s\n", target, name)
return nil
})
}
// applyAgentMounts recreates the agent with the desired mount set and only
// then persists it, so state and container cannot diverge in a way a retry
// of the same command would skip: a recreation failure leaves the old state
// (the retry is not an idempotent no-op), and a persisted set always
// describes a container that actually carried it.
func applyAgentMounts(ctx context.Context, cli *docker.Client, b *box.Box, name string, mounts []box.AgentMount) error {
token, err := box.Token(b.Name)
if err != nil {
return err
}
wasRunning, err := box.RecreateAgent(ctx, cli, b, token, mounts)
if err != nil {
return err
}
if err := box.SaveAgentMounts(name, mounts); err != nil {
return fmt.Errorf("agent was recreated with the desired mounts but persisting them failed; re-run the command: %w", err)
}
if !wasRunning {
return nil
}
// A quiesced box (e.g. freshly imported, single-writer safety gate) must
// stay quiesced: PrepareRuntime runs `hb up`, which clears the quiesce
// marker and wires MCP — exactly what cmd_import deliberately avoids.
// An inconclusive check is an error, not "active": guessing wrong would
// unquiesce the box. The mounts themselves are already applied and
// saved; the box's own workload loop reconciles the runtime regardless.
quiesced, err := box.AgentQuiesced(ctx, cli, b, token)
if err != nil {
return fmt.Errorf("mounts are applied, but could not determine whether the box is quiesced; skipped runtime preparation: %w", err)
}
if quiesced {
fmt.Println("tx9: box is quiesced; skipping runtime preparation (run `hb resume` inside the box when ready)")
return nil
}
fmt.Println("tx9: preparing recreated agent runtime")
return box.PrepareRuntime(ctx, cli, b, token, os.Stdout)
}
func mountMode(mount box.AgentMount) string {
if mount.ReadOnly {
return "ro"
}
return "rw"
}