From 103f2cd33ead6c25adf157faa424f9aa52949c64 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 19:49:29 -0400 Subject: [PATCH] feat(v0.5): launch agent inside project subdirectory via launch_dir LaunchOpts gains LaunchDir. session.Run resolves it via workspace.ResolveLaunch, prints any fallback warning, and passes the absolute path as the child process's working directory. Security violations (absolute paths, .. escape) abort the session. The banner gains a 'project dir: /' line when launch_dir is set. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/new.go | 1 + cmd/open.go | 15 ++++++++------- cmd/resume.go | 15 ++++++++------- internal/session/run.go | 25 ++++++++++++++++++++++--- internal/shell/launch.go | 9 +++++++-- internal/shell/launch_test.go | 23 ++++++++++++++++++++++- 6 files changed, 68 insertions(+), 20 deletions(-) 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."