diff --git a/cmd/new.go b/cmd/new.go index 1e4e182..bce7eb2 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -128,6 +128,7 @@ func runNew(cmd *cobra.Command, args []string) error { Mode: ws.Meta.Mode, Slug: ws.Meta.Slug, Shell: newShell, + LaunchDir: ws.Meta.LaunchDir, NewlyCreated: true, }) } diff --git a/cmd/open.go b/cmd/open.go index 3ed137c..c33b106 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -45,12 +45,13 @@ func runOpen(cmd *cobra.Command, args []string) error { envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, ws.Root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: ws.Meta.Agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: true, // open always launches shell - Force: openForce, + WsDir: ws.Path, + EnvVars: envVars, + Agent: ws.Meta.Agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: true, // open always launches shell + LaunchDir: ws.Meta.LaunchDir, + Force: openForce, }) } diff --git a/cmd/resume.go b/cmd/resume.go index f5d5019..00ee788 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -65,12 +65,13 @@ func doResume(query string, container, useShell, force bool, agentOverride strin envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, ws.Root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: useShell, - Force: force, + WsDir: ws.Path, + EnvVars: envVars, + Agent: agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: useShell, + LaunchDir: ws.Meta.LaunchDir, + Force: force, }) } diff --git a/internal/session/run.go b/internal/session/run.go index 41022ae..d4ec699 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -9,6 +9,7 @@ import ( "github.com/warrenronsiek/ctask/internal/lockfile" "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" ) // LaunchOpts configures a session launch. @@ -20,6 +21,12 @@ type LaunchOpts struct { Slug string Shell bool // true = interactive shell, false = agent + // LaunchDir is the workspace-relative launch directory (v0.5). Empty for + // tasks and pre-v0.5 projects. When set, Run resolves the absolute path + // via workspace.ResolveLaunch and uses it as the child's working dir; + // a security violation (absolute path or .. escape) aborts the session. + LaunchDir string + // Force suppresses both the active-session warning (Layer 1) and the // stale-workspace warning (Layer 3). It does NOT disable the metadata // write lock or the session summary. Used for scripted/automated runs. @@ -138,15 +145,27 @@ func Run(opts LaunchOpts) error { hb = StartHeartbeat(leasePath, HeartbeatInterval) } + // ---- Resolve launch directory (v0.5) ---- + // Security violations (absolute path, ..-escape) abort the session + // before we hand control to the child. Missing/non-dir falls back to + // wsDir with a warning. + launchAbs, launchWarn, launchErr := workspace.ResolveLaunch(opts.WsDir, opts.LaunchDir) + if launchErr != nil { + return fmt.Errorf("resolving launch_dir: %w", launchErr) + } + if launchWarn != "" { + fmt.Fprintln(os.Stderr, launchWarn) + } + // ---- Run the child ---- var childErr error if opts.Shell { - childErr = shell.ExecShell(opts.WsDir, opts.EnvVars, opts.Slug, opts.Mode) + childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode) } else { - for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir) { + for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { fmt.Println(line) } - childErr = shell.ExecAgent(opts.Agent, opts.WsDir, opts.EnvVars) + childErr = shell.ExecAgent(opts.Agent, launchAbs, opts.EnvVars) } if hb != nil { diff --git a/internal/shell/launch.go b/internal/shell/launch.go index 31796a0..c19d8bd 100644 --- a/internal/shell/launch.go +++ b/internal/shell/launch.go @@ -37,11 +37,16 @@ func PromptPrefix(slug, mode string) string { } // BannerLines returns the launch banner lines for agent mode. -func BannerLines(mode, slug, wsPath string) []string { - return []string{ +// Adds a "project dir:" line when launchDir is non-empty. +func BannerLines(mode, slug, wsPath, launchDir string) []string { + lines := []string{ fmt.Sprintf("[ctask] %s :: %s", mode, slug), fmt.Sprintf("[ctask] %s", wsPath), } + if launchDir != "" { + lines = append(lines, fmt.Sprintf("[ctask] project dir: %s/", launchDir)) + } + return lines } // ContainerNotice returns the v0.1 deferred container mode message. diff --git a/internal/shell/launch_test.go b/internal/shell/launch_test.go index 1ff2702..421a18d 100644 --- a/internal/shell/launch_test.go +++ b/internal/shell/launch_test.go @@ -2,6 +2,7 @@ package shell import ( "runtime" + "strings" "testing" ) @@ -44,7 +45,7 @@ func TestPromptPrefix(t *testing.T) { } func TestBannerLines(t *testing.T) { - lines := BannerLines("local", "my-task", "/home/user/ws/general/2026-04-05_my-task") + lines := BannerLines("local", "my-task", "/home/user/ws/general/2026-04-05_my-task", "") if len(lines) != 2 { t.Fatalf("expected 2 banner lines, got %d", len(lines)) } @@ -56,6 +57,26 @@ func TestBannerLines(t *testing.T) { } } +func TestBannerLinesIncludesLaunchDirWhenSet(t *testing.T) { + lines := BannerLines("local", "demo", "/ws/2026-04-22_demo", "demo") + if len(lines) < 3 { + t.Fatalf("expected at least 3 lines when launch dir set, got %d: %v", len(lines), lines) + } + if !strings.Contains(lines[2], "project dir") { + t.Errorf("third line should mention 'project dir', got %q", lines[2]) + } + if !strings.Contains(lines[2], "demo/") { + t.Errorf("third line should include 'demo/', got %q", lines[2]) + } +} + +func TestBannerLinesOmitsLaunchDirWhenEmpty(t *testing.T) { + lines := BannerLines("local", "demo", "/ws/2026-04-22_demo", "") + if len(lines) != 2 { + t.Errorf("expected 2 lines when launch dir empty, got %d: %v", len(lines), lines) + } +} + func TestContainerNotice(t *testing.T) { msg := ContainerNotice() expected := "[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup."