Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/plan.md

Large diffs are not rendered by default.

25 changes: 18 additions & 7 deletions internal/cli/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ type guardOutput struct {
// hookGuard is the plugin's preToolUse hook, which runs before each tool that a pane's copilot
// calls. In a role's pane, it denies a bash script that the guard can't read, or that runs a
// command that the guard watches other than baton's commands for a role, and a create or edit of
// a file in baton's folder. It says nothing in the orchestrator's pane yet. Copilot denies the
// tool when the hook fails, so baton then reports why and exits with 1 (decision 52).
// a file in baton's folder or in baton's cache folder, which holds the plugin. It says nothing in
// the orchestrator's pane yet. Copilot denies the tool when the hook fails, so baton then reports
// why and exits with 1 (decisions 52 and 53).
func hookGuard(ctx context.Context, env Env, args []string) error {
if err := parse(newFlags(env, "hook guard"), args); err != nil {
return err
Expand Down Expand Up @@ -128,8 +129,14 @@ func scriptReason(script string) string {
}

// pathReason returns the reason to deny a file tool's write in a role's pane, or "" to let it run:
// a file in baton's folder, which only baton writes (decision 48).
// a file in baton's folder, which only baton writes (decision 48), or in baton's cache folder,
// whose plugin runs this guard (decision 53).
func pathReason(ctx context.Context, dir, path string) (string, error) {
cache, err := batonCache()
if err != nil {
return "", err
}

store, err := openStore(ctx, dir)
if err != nil {
return "", err
Expand All @@ -144,12 +151,16 @@ func pathReason(ctx context.Context, dir, path string) (string, error) {
path = abs
}

if !within(path, store.Dir) {
return "", nil
switch {
case within(path, store.Dir):
return fmt.Sprintf("%s%s is in baton's folder %s, which only baton writes: write your "+
"notes in your session's files/ folder.", stopped, path, store.Dir), nil
case within(path, cache):
return fmt.Sprintf("%s%s is in baton's cache folder %s, which only baton writes: it "+
"holds the plugin whose hook runs this guard.", stopped, path, cache), nil
}

return fmt.Sprintf("%s%s is in baton's folder %s, which only baton writes: write your notes "+
"in your session's files/ folder.", stopped, path, store.Dir), nil
return "", nil
}

// within reports whether the path is the folder or lies in it, by the files that they name, so
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/guard_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/codedogapp/baton/internal/cli"
"github.com/codedogapp/baton/internal/flow"
"github.com/codedogapp/baton/internal/plugin"
)

// TestGuardHookKeepsTheFileToolsOutOfBatonsFolder denies a role's create and edit of a file in
Expand Down Expand Up @@ -69,6 +70,68 @@ func TestGuardHookKeepsTheFileToolsOutOfBatonsFolder(t *testing.T) {
}
}

// TestGuardHookKeepsTheFileToolsOutOfBatonsCache denies a role's create and edit of a file in
// baton's cache folder, which holds the plugin whose hook runs the guard, however the path names
// it (decision 53). It lets them write beside it.
func TestGuardHookKeepsTheFileToolsOutOfBatonsCache(t *testing.T) {
_, cache := standIn(t)
_, linked := repos(t)

dir, err := plugin.Write(cache)
if err != nil {
t.Fatal(err)
}

link := filepath.Join(linked, "cache")
if err := os.Symlink(cache, link); err != nil {
t.Fatal(err)
}

hooks := filepath.Join(dir, hooksFile)
cases := []struct {
name string
path string // The path in the tool's arguments.
want string // The path that the reason names, or "" to let the tool run.
}{
{"the plugin's hooks", hooks, hooks},
{"baton's cache folder", cache, cache},
{"a file in a new folder", cache + "/plugin-0/x.md", cache + "/plugin-0/x.md"},
{"a symlink", link + "/x.md", link + "/x.md"},
{"a folder beside it", cache + "-old/x.md", ""},
}

for _, test := range cases {
for _, tool := range []string{"create", "edit"} {
t.Run(tool+" "+test.name, func(t *testing.T) {
want := ""
if test.want != "" {
want = guardStopped + test.want + " is in baton's cache folder " + cache +
", which only baton writes: it holds the plugin whose hook runs this guard."
}

input := guardInput(t, linked, tool, map[string]any{"path": test.path})
wantGuard(t, guardHook(t, linked, paneOf(flow.RoleEngineer), input), want)
})
}
}
}

// TestGuardHookFailsWithoutTheCacheFolder fails, and so denies a file tool, when it can't find
// the user's cache folder, which holds the plugin.
func TestGuardHookFailsWithoutTheCacheFolder(t *testing.T) {
_, linked := repos(t)
t.Setenv("HOME", "")
t.Setenv("XDG_CACHE_HOME", "")

input := guardInput(t, linked, "create", map[string]any{"path": linked + "/x.md"})
got := guardHook(t, linked, paneOf(flow.RoleLead), input)

want := "baton hook guard: find the cache folder: "
if got.code != 1 || got.stdout != "" || !strings.HasPrefix(got.stderr, want) {
t.Errorf("got %+v; want exit code 1 and %q", got, want)
}
}

// TestGuardHookFindsBatonsFolderFromItsOwnFolder finds baton's folder from the hook's own folder
// when the input names no folder, and a relative path starts there.
func TestGuardHookFindsBatonsFolderFromItsOwnFolder(t *testing.T) {
Expand Down
37 changes: 29 additions & 8 deletions internal/cli/pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const envBin = "BATON_BIN"
// The orchestrator's copilot starts at once, since the user talks to it. A role's pane waits for
// the role's first work, then becomes the role's copilot, with that work as its first prompt
// (decision 38). It finds copilot, writes the plugin and reads the settings before it waits, so
// that a pane that can't start copilot never picks up work that nobody would then do.
// that a pane that can't start copilot never picks up work that nobody would then do. After the
// wait it checks the plugin again, which a role may have changed meanwhile (decision 53).
func pane(ctx context.Context, env Env, args []string) error {
if err := parse(newFlags(env, "pane"), args); err != nil {
return err
Expand Down Expand Up @@ -68,6 +69,10 @@ func pane(ctx context.Context, env Env, args []string) error {
return err
}

if dir, err = writePlugin(); err != nil {
return err
}

return becomeCopilot(env, program, command(role, dir, own, work),
environment(bin, allowAll+"=true"))
}
Expand Down Expand Up @@ -108,25 +113,41 @@ func becomeCopilot(env Env, program string, line, vars []string) error {
return nil
}

// prepare finds copilot and writes the plugin into baton's cache folder, and returns copilot's
// path and the plugin's folder.
// prepare finds copilot and writes the plugin, and returns copilot's path and the plugin's folder.
func prepare() (program, dir string, err error) {
program, err = exec.LookPath("copilot")
if err != nil {
return "", "", err
}

cache, err := os.UserCacheDir()
dir, err = writePlugin()
if err != nil {
return "", "", fmt.Errorf("find the cache folder: %w", err)
return "", "", err
}

dir, err = plugin.Write(filepath.Join(cache, cacheFolder))
return program, dir, nil
}

// writePlugin writes the plugin into baton's cache folder, unless a folder there holds it
// already, unchanged, and returns the plugin's folder (decisions 36 and 53).
func writePlugin() (string, error) {
cache, err := batonCache()
if err != nil {
return "", "", err
return "", err
}

return program, dir, nil
return plugin.Write(cache)
}

// batonCache returns baton's folder in the user's cache folder, which holds the plugin (decision
// 36).
func batonCache() (string, error) {
cache, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("find the cache folder: %w", err)
}

return filepath.Join(cache, cacheFolder), nil
}

// watch reads the pane's terminal, and drops what the user types there, until the terminal's
Expand Down
86 changes: 86 additions & 0 deletions internal/cli/pane_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli_test

import (
"os"
"path/filepath"
"testing"
"testing/synctest"

"github.com/codedogapp/baton/internal/flow"
"github.com/codedogapp/baton/internal/state"
)

// hooksFile is the plugin's hooks file, in the plugin's folder.
var hooksFile = filepath.Join("com.github.copilot", "hooks", "hooks.json")

// TestPaneChecksThePluginAgainAfterTheWait has the engineer's pane, whose plugin a role changes
// while it waits, start the engineer's copilot with the plugin written again, under the folder's
// next name, and leave the changed folder as it is (decision 53).
func TestPaneChecksThePluginAgainAfterTheWait(t *testing.T) {
copilot, cache := standIn(t)

synctest.Test(t, func(t *testing.T) {
var first string

got, store := handedOverAfter(t, func() {
first = pluginIn(t, cache)
write(t, first, hooksFile, "{}")
})

wantCopilot(t, got, flow.RoleEngineer, execed{
path: copilot,
args: copilotLine(flow.RoleEngineer, first+"-1",
"Picked up demo-1 as engineer: round 1 of 3, from lead.\nNote: "+
notePath(store, "02-lead-to-engineer.md")+"\n"),
env: copilotEnv(),
})

hooks := filepath.Clean(filepath.Join(first, hooksFile))
if data, err := os.ReadFile(hooks); string(data) != "{}" {
t.Errorf("the changed hooks file holds %q, %v; want it as the role left it", data, err)
}
})
}

// TestPaneFailsWhenItCantWriteThePluginAfterTheWait has the engineer's pane, whose cache folder
// a file takes the place of while it waits, fail after it picks up the engineer's work, and start
// no copilot: the work stays picked up, as when copilot fails to start (decision 38).
func TestPaneFailsWhenItCantWriteThePluginAfterTheWait(t *testing.T) {
_, cache := standIn(t)

synctest.Test(t, func(t *testing.T) {
got, store := handedOverAfter(t, func() { fileFor(t, cache) })

wantRefused(t, got, "engineer in demo-1: waiting for work.\n",
"make the cache folder: mkdir "+cache+": not a directory\n")
wantWork(t, store, state.Work{
Note: "02-lead-to-engineer.md", From: flow.RoleLead, Picked: true,
})
})
}

// handedOverAfter starts the engineer's pane, which waits for its work, and makes the change while
// it waits; the lead then picks up its own work and hands over to the engineer. It returns what
// the engineer's pane did, and the feature's store.
func handedOverAfter(t *testing.T, change func()) (ran, state.Store) {
t.Helper()

mainDir, linked := repos(t)
store := started(t, mainDir, fresh())

env, _ := inPane(t, linked, paneOf(flow.RoleEngineer))
engineer := make(chan ran, 1)

go func() { engineer <- paneFor(t, env) }()

synctest.Wait()
change()

if got := waitAs(t, mainDir, flow.RoleLead); got.code != 0 {
t.Fatalf("the lead's wait: %+v", got)
}

handOver(t, mainDir, flow.RoleLead, flow.VerdictNone)

return <-engineer, store
}
Loading
Loading