diff --git a/.surface b/.surface index 618cfc90f..68eba3d39 100644 --- a/.surface +++ b/.surface @@ -5512,12 +5512,12 @@ FLAG basecamp connect setup --operator-profile type=string FLAG basecamp connect setup --profile type=string FLAG basecamp connect setup --project type=string FLAG basecamp connect setup --quiet type=bool -FLAG basecamp connect setup --remove-route type=stringArray -FLAG basecamp connect setup --route type=stringArray +FLAG basecamp connect setup --serve type=stringArray FLAG basecamp connect setup --stats type=bool FLAG basecamp connect setup --styled type=bool FLAG basecamp connect setup --todolist type=string FLAG basecamp connect setup --trust type=string +FLAG basecamp connect setup --unserve type=stringArray FLAG basecamp connect setup --verbose type=count FLAG basecamp connect setup --watch-completions type=stringArray FLAG basecamp connect setup --worker type=string diff --git a/.surface-breaking b/.surface-breaking index 90a035eed..48c4d5bb5 100644 --- a/.surface-breaking +++ b/.surface-breaking @@ -1146,3 +1146,5 @@ SUB basecamp uploads vault list SUB basecamp uploads vaults SUB basecamp uploads vaults create SUB basecamp uploads vaults list +FLAG basecamp connect setup --remove-route type=stringArray +FLAG basecamp connect setup --route type=stringArray diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dbdc44d05..29c5cc58c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -200,7 +200,12 @@ func (m *Manager) loginRemedy() (command, lead string) { // shellQuote renders s safe to embed in an emitted shell command: a clearly // inert name passes through bare, anything else is single-quoted — the one // POSIX form in which nothing substitutes — with embedded single quotes -// spelled '\”. Profile names come from configuration files, which do not +// spelled: +// +// '\'' +// +// indented so gofmt leaves it as written. Profile names come from +// configuration files, which do not // apply the create-time name check. func shellQuote(s string) string { if s != "" && strings.IndexFunc(s, shellActive) < 0 { diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 557b51751..8ccf841a3 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -40,7 +40,7 @@ the work to a local coding agent that replies in Basecamp as the agent. Connect the agent to a profile first (basecamp auth agent connect -P ), then run setup on that profile: it records who may drive the agent, maps -projects to the directories their work runs in, and checks the connector is +which Basecamp projects it serves, and checks the connector is ready. Show prints what setup recorded. Then run the connector on it: basecamp connect -P [--project ]... [--shadow] @@ -61,7 +61,7 @@ wait for review, until basecamp connect release. Linux only. basecamp connect release clear the hold basecamp connect shadow promote make the shadow ledger the connector's, held basecamp connect import apply a cutover reconciliation file`, - Example: ` basecamp connect setup -P agent --operator-profile me --route 12345=/src/app + Example: ` basecamp connect setup -P agent --operator-profile me --serve 12345 basecamp connect -P agent basecamp connect -P agent --project 12345 --shadow`, Args: cobra.NoArgs, @@ -88,7 +88,7 @@ func newConnectShowCmd() *cobra.Command { Use: "show", Short: "Show a profile's connector setup without changing it", Long: `Show the connect.json a profile's setup wrote: the agent, the operator and -trust mode, each routed project and the worker settings. +trust mode, each served project and the worker settings. The file is read through the same checks setup and the connector apply: it is refused, and nothing of it shown, when it is a symlink, is not a @@ -130,7 +130,7 @@ func runConnectShow(app *appctx.App) error { switch { case errors.Is(err, os.ErrNotExist): return output.ErrNotFoundHint("connect.json for profile", name, - "The profile has not been set up. Set it up: basecamp connect setup -P "+shellQuote(name)+" --operator-profile '' --route '='") + "The profile has not been set up. Set it up: basecamp connect setup -P "+shellQuote(name)+" --operator-profile '' --serve ") case err != nil && runtime.GOOS == "windows": return output.ErrUsageHint("connect.json cannot be used: "+setup.ErrorText(err), "The connector's setup is not supported on Windows: this CLI cannot verify who can change connect.json there.") @@ -146,18 +146,18 @@ func runConnectShow(app *appctx.App) error { } // The generic object renderer drops nested maps, which is where the - // agent, the trust and the routes live, so a person's formats get them + // agent, the trust and the served projects live, so a person's formats get them // flattened to one line each; JSON keeps the file's own shape. markdown := app.Output.EffectiveFormat() == output.FormatMarkdown return app.OK(connectShowResult{Path: path, File: f}, output.WithDisplayData(connectShowDisplay(path, f, markdown)), - output.WithSummary(fmt.Sprintf("Connector setup for profile %q: trust %s, %d routed project(s)", name, f.Trust.Mode, len(f.Projects)))) + output.WithSummary(fmt.Sprintf("Connector setup for profile %q: trust %s, %d served project(s)", name, f.Trust.Mode, len(f.Projects)))) } // connectShowDisplay is show's data for a person: every setting connect.json -// records as a flat field, one per route. Paths are shown exactly: quoted, -// or as a code span in Markdown, so nothing in one renders as formatting or -// reaches a terminal as a control byte. +// records as a flat field, one per served project. The file's own path is +// shown exactly — quoted, or as a code span in Markdown — so nothing in it +// renders as formatting or reaches a terminal as a control byte. func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any { exact := func(s string) string { // strconv.Quote escapes controls and backslashes; "<" is escaped too, @@ -191,17 +191,17 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any // existed still means the default, which is what a person reading // show needs to see. "workers": fmt.Sprintf("%s running %s, concurrency %d, deadline %s", f.Driver, f.WorkerName(), f.Concurrency, time.Duration(f.Deadline)), - "projects": strconv.Itoa(len(f.Projects)) + " routed", + "projects": strconv.Itoa(len(f.Projects)) + " served", } for id, r := range f.Projects { - route := exact(r.Path) + settings := "served" if r.Class != "" { - route += ", class " + r.Class + settings += ", class " + r.Class } if r.WatchCompletions { - route += ", watches completions" + settings += ", watches completions" } - d[fmt.Sprintf("route_%d", id)] = route + d[fmt.Sprintf("project_%d", id)] = settings } return d } @@ -260,11 +260,11 @@ type connectSetupFlags struct { trust string allow []string - routes []string + serve []string classes []string watch []string unwatch []string - unroute []string + unserve []string driver string worker string parallel int @@ -276,7 +276,7 @@ func newConnectSetupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "setup", - Short: "Choose who may drive a connected agent, route projects, and check readiness", + Short: "Choose who may drive a connected agent, serve projects, and check readiness", Long: `Set up the connector for the agent a profile holds: record the trusted operator, write connect.json, and check what can be checked before the connector runs. @@ -300,10 +300,16 @@ the people passed with --allow. project: the operator and any non-client member of the event's project. Assignments are the operator's alone in every mode. -Routes. connect.json is the only authority for which directory a project's -work runs in: --route =. A project with no route gets a -holding reply and no work. --watch-completions makes the agent -hear every trusted completion in that project without being assigned. +Projects. connect.json is the local list of Basecamp projects this agent +serves: --serve , --unserve . In a project it does +not serve, a mention from a trusted person or an assignment from the operator +gets a holding reply and no work; anything else is discarded unanswered. --watch-completions +makes the agent hear every trusted completion in that project without being +assigned. + +No directory is associated with a project. The connector runs where it is +started, every worker runs there too, and a task that needs a clone or a +directory of its own is the agent's own business to make. connect.json is written owner-only and refused when anyone else could have changed it or a directory above it. Where this CLI cannot verify that @@ -322,9 +328,9 @@ Run setup again to change any of it; what you do not pass is kept. Examples: basecamp auth agent connect -P agent - basecamp connect setup -P agent --operator-profile me --route 12345=~/Work/app + basecamp connect setup -P agent --operator-profile me --serve 12345 basecamp connect setup -P agent --operator-profile me --trust allowlist --allow 111 --allow 222 - basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --route 12345=~/Work/app + basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --serve 12345 basecamp connect setup -P agent --class 12345=internal --deadline 90m`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -342,10 +348,10 @@ Examples: fl.StringVar(&f.operatorProfile, "operator-profile", "", "Profile whose identity is the operator") fl.StringVar(&f.trust, "trust", "", "Who may drive the agent: operator, allowlist or project") fl.StringArrayVar(&f.allow, "allow", nil, "Person id to trust besides the operator (repeatable; implies --trust allowlist)") - fl.StringArrayVar(&f.routes, "route", nil, "Route a project to a directory: = (repeatable)") - fl.StringArrayVar(&f.unroute, "remove-route", nil, "Remove a project's route (repeatable)") - fl.StringArrayVar(&f.classes, "class", nil, "Classify a routed project: =, or = to clear it (repeatable)") - fl.StringArrayVar(&f.watch, "watch-completions", nil, "Admit every trusted completion in a routed project (repeatable)") + fl.StringArrayVar(&f.serve, "serve", nil, "Serve a Basecamp project: (repeatable)") + fl.StringArrayVar(&f.unserve, "unserve", nil, "Stop serving a project (repeatable)") + fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: =, or = to clear it (repeatable)") + fl.StringArrayVar(&f.watch, "watch-completions", nil, "Admit every trusted completion in a served project (repeatable)") fl.StringArrayVar(&f.unwatch, "no-watch-completions", nil, "Stop watching a project's completions (repeatable)") fl.StringVar(&f.driver, "driver", "", "How workers are run: spawn or acp (default spawn)") fl.StringVar(&f.worker, "worker", "", fmt.Sprintf("The coding agent workers run: %s (default %s)", strings.Join(setup.Workers, ", "), setup.DefaultWorker)) @@ -551,11 +557,11 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags) AgentKind: kind, OperatorID: next.Trust.OperatorID, TrustMode: string(next.Trust.Mode), - Routes: len(next.Projects), + Projects: len(next.Projects), } report.Add(checks...) report.Add(setup.TicketCheck(ctx, reader, kind)) - report.Add(setup.RouteChecks(ctx, reader, next)...) + report.Add(setup.ProjectChecks(ctx, reader, next, !exists)...) // A command the person stopped did not find the connector unready: it // found nothing, and says so as an interruption. if err := ctx.Err(); err != nil { @@ -741,10 +747,15 @@ func (f *connectSetupFlags) changes(cmd *cobra.Command) (setup.Changes, error) { } } - var err error - if ch.Routes, err = parseProjectPairs("--route", f.routes); err != nil { - return ch, err + for _, raw := range f.serve { + id, err := parsePositiveID("--serve", raw) + if err != nil || id == 0 { + return ch, output.ErrUsage(fmt.Sprintf("Invalid --serve %q: expected a project id", raw)) + } + ch.Serve = append(ch.Serve, id) } + + var err error if ch.Classes, err = parseProjectPairsAllowEmpty("--class", f.classes); err != nil { return ch, err } @@ -767,10 +778,10 @@ func (f *connectSetupFlags) changes(cmd *cobra.Command) (setup.Changes, error) { ch.WatchCompletions[id] = list.on } } - for _, raw := range f.unroute { - id, err := parsePositiveID("--remove-route", raw) + for _, raw := range f.unserve { + id, err := parsePositiveID("--unserve", raw) if err != nil || id == 0 { - return ch, output.ErrUsage(fmt.Sprintf("Invalid --remove-route %q: expected a project id", raw)) + return ch, output.ErrUsage(fmt.Sprintf("Invalid --unserve %q: expected a project id", raw)) } ch.Remove = append(ch.Remove, id) } @@ -802,18 +813,11 @@ func (f *connectSetupFlags) changes(cmd *cobra.Command) (setup.Changes, error) { return ch, nil } -// parseProjectPairs parses repeatable = flags. -func parseProjectPairs(flag string, raw []string) (map[int64]string, error) { - return parseProjectPairsWith(flag, raw, false) -} - -// parseProjectPairsAllowEmpty is parseProjectPairs where = (an -// empty value) is meaningful: it clears the setting. +// parseProjectPairsAllowEmpty parses repeatable = flags. +// = (an empty value) is meaningful: it clears the setting. The +// only such flag left is --class, and only it ever had an empty value to +// mean anything. func parseProjectPairsAllowEmpty(flag string, raw []string) (map[int64]string, error) { - return parseProjectPairsWith(flag, raw, true) -} - -func parseProjectPairsWith(flag string, raw []string, allowEmpty bool) (map[int64]string, error) { if len(raw) == 0 { return nil, nil } @@ -821,7 +825,7 @@ func parseProjectPairsWith(flag string, raw []string, allowEmpty bool) (map[int6 for _, pair := range raw { idText, value, ok := strings.Cut(pair, "=") id, err := parsePositiveID(flag, strings.TrimSpace(idText)) - if !ok || err != nil || id == 0 || (value == "" && !allowEmpty) { + if !ok || err != nil || id == 0 { return nil, output.ErrUsage(fmt.Sprintf("Invalid %s %q: expected =", flag, pair)) } if _, dup := out[id]; dup { diff --git a/internal/commands/connect_doctor.go b/internal/commands/connect_doctor.go index fcefda263..5fdcda3b5 100644 --- a/internal/commands/connect_doctor.go +++ b/internal/commands/connect_doctor.go @@ -39,8 +39,8 @@ losses, hold and messages waiting for a person), the worker the driver runs — the worker's own CLI on PATH under the spawn driver, the pinned ACP adapter in the connector's adapters directory under the acp driver, and the adapter's own refusal of configuration on this machine that -the connector cannot switch off, run in the directory every routed project -would work in — and a handshake with the agent's Basecamp MCP server, started with a worker's +the connector cannot switch off, checked in the directory this command runs +in — and a handshake with the agent's Basecamp MCP server, started with a worker's environment (without the basecamp_connect domain, which only a dispatched task's token opens). @@ -248,26 +248,23 @@ func acpAdapterCheck(worker string) setup.Check { } // acpPreflightCheck runs the adapter's own preflight — the refusal the acp -// driver makes before it starts anything — for the directories a dispatch -// would run in. It is the check a resolved adapter does not make: the -// preflight reads configuration on this machine the connector cannot switch -// off (a Codex config layer that declares MCP servers, which codex-acp would -// load into the session beside the connector's), so a profile whose adapter -// is installed and on the pin can still have every record refused with +// driver makes before it starts anything — for the directory a dispatch would +// run in. It is the check a resolved adapter does not make: the preflight +// reads configuration on this machine the connector cannot switch off (a +// Codex config layer that declares MCP servers, which codex-acp would load +// into the session beside the connector's), so a profile whose adapter is +// installed and on the pin can still have every record refused with // ErrUnusable the moment it is dispatched. There is no second check: doctor // refuses what the run command refuses. // -// Every routed directory, not the first, and every distinct reason rather -// than the first: the preflight walks per-directory layers as well as the -// machine's, so one route can carry a .codex/config.toml another has not, -// and a person fixing this wants the whole list out of one run. It costs a -// handful of file reads per route. The layers every route shares — the -// user's and the system's — fail identically, so an identical reason is -// reported once, for all of them. +// Every distinct reason rather than the first: the preflight walks the +// directory's own layers as well as the machine's, and a person fixing this +// wants the whole list out of one run. // -// What it runs against is the directory a dispatch would give the session, -// which is the route itself: the connector runs a task where its route -// says, and prepares nothing. +// It runs in this command's own working directory, which is what a dispatch +// would give the session only if the connector was started in the same place: +// the connector runs where it is started, and nothing records where that was. +// Doctor says which directory it checked for that reason. // // The second return is false when there is nothing to run: an adapter with // no preflight (claude-agent-acp) gets no row, rather than a row saying a @@ -278,80 +275,52 @@ func acpPreflightCheck(file setup.File) (setup.Check, bool) { return setup.Check{}, false } c := setup.Check{Name: "Adapter " + a.Name + " preflight"} - ids := make([]int64, 0, len(file.Projects)) - for id := range file.Projects { - ids = append(ids, id) - } - slices.Sort(ids) - if len(ids) == 0 { - c.Status = setup.StatusSkip - c.Message = "No project is routed, so there is no directory a session would run in" + dir, err := os.Getwd() + if err != nil { + c.Status = setup.StatusFail + c.Message = "This command's own working directory could not be read, and it is where a session would start: " + errorMessage(err) return c, true } + where := richtext.SanitizeSingleLine(dir) - type failure struct { - reason string - routes []string - hint string - rank int - } - var failures []*failure - seen := map[string]*failure{} - for _, id := range ids { - path := file.Projects[id].Path - // Each refusal apart, not the session's whole refusal: a layer every - // route shares is one reason for all of them, and a route that has a - // second one of its own must not turn the shared one into a reason - // of its own too. - errs := acp.Refusals(acp.Preflight(a, path, nil, nil)) - for _, err := range errs { - if err == nil { - continue - } - reason := preflightReason(err) - f, ok := seen[reason] - if !ok { - hint, rank := preflightHint(err) - f = &failure{reason: reason, hint: hint, rank: rank} - seen[reason] = f - failures = append(failures, f) - } - f.routes = append(f.routes, richtext.SanitizeSingleLine(path)) + // Each refusal apart, not the session's whole refusal, so a directory + // with a second layer of its own does not fold into the machine's. + var reasons []string + var ranked []error + for _, err := range acp.Refusals(acp.Preflight(a, dir, nil, nil)) { + if err == nil { + continue + } + reason := preflightReason(err) + if slices.Contains(reasons, reason) { + continue } + reasons = append(reasons, reason) + ranked = append(ranked, err) } - if len(failures) == 0 { + if len(reasons) == 0 { c.Status = setup.StatusPass - c.Message = fmt.Sprintf("%s would start in every routed directory (%d checked)", a.Name, len(ids)) + c.Message = fmt.Sprintf("%s would start in %s", a.Name, where) return c, true } c.Status = setup.StatusFail - parts := make([]string, 0, len(failures)) - for i, f := range failures { - where := strings.Join(f.routes, ", ") - if len(ids) > 1 && len(f.routes) == len(ids) { - // Every route, one reason: the user's or the system's layer, - // which no route escapes by being somewhere else. - where = "any routed directory" - } - lead := "no session would start in" - if i == 0 { - lead = "No session would start in" - } - parts = append(parts, fmt.Sprintf("%s %s: %s", lead, where, f.reason)) - } - c.Message = strings.Join(parts, "; ") + c.Message = fmt.Sprintf("No session would start in %s: %s", where, strings.Join(reasons, "; ")) // Every distinct remedy, most pressing first: the message names more // than one reason, and a person fixing them needs what to do about each. // Order by rank rather than by the order the layers happen to be read // in, so a file that could not be read in /etc does not come before a // declaration that is certainly there. - ranked := slices.Clone(failures) - slices.SortStableFunc(ranked, func(a, b *failure) int { return a.rank - b.rank }) + slices.SortStableFunc(ranked, func(a, b error) int { + _, ra := preflightHint(a) + _, rb := preflightHint(b) + return ra - rb + }) hints := make([]string, 0, len(ranked)) - for _, f := range ranked { - if !slices.Contains(hints, f.hint) { - hints = append(hints, f.hint) + for _, err := range ranked { + hint, _ := preflightHint(err) + if !slices.Contains(hints, hint) { + hints = append(hints, hint) } } c.Hint = strings.Join(hints, " ") diff --git a/internal/commands/connect_operator.go b/internal/commands/connect_operator.go index b5fa30277..f906f3065 100644 --- a/internal/commands/connect_operator.go +++ b/internal/commands/connect_operator.go @@ -8,6 +8,7 @@ import ( "os" "os/user" "path/filepath" + "slices" "strconv" "strings" "time" @@ -37,6 +38,20 @@ type connectProfile struct { file setup.File } +// servedBucketsOf is the projects a connect.json serves, as the ledger's +// decisions want them, from the file this command loaded when it started. +// That is a reading and not a lock: nothing stops `connect setup --unserve` +// completing between it and the write below, and holding the setup lock +// across both is carded rather than done here. +func servedBucketsOf(file setup.File) []int64 { + served := make([]int64, 0, len(file.Projects)) + for bucket := range file.Projects { + served = append(served, bucket) + } + slices.Sort(served) + return served +} + func loadConnectProfile(cmd *cobra.Command) (connectProfile, error) { app := appctx.FromContext(cmd.Context()) if app == nil { @@ -327,8 +342,8 @@ func renderConnectStatus(w io.Writer, r connectStatusReport) { fmt.Fprintf(w, "\n Live tasks %d\n", len(s.Tasks)) for _, t := range s.Tasks { - fmt.Fprintf(w, " task %d %s %s pid %d (%s) token taker pid %d (%s) since %s events %v in %s\n", - t.TaskID, clean(t.AttemptID), clean(t.State), t.PID, clean(t.Worker), t.TakerPID, clean(t.Taker), stamp(t.LaunchedAt), t.EventIDs, clean(t.WorkDir)) + fmt.Fprintf(w, " task %d %s %s pid %d (%s) token taker pid %d (%s) since %s events %v\n", + t.TaskID, clean(t.AttemptID), clean(t.State), t.PID, clean(t.Worker), t.TakerPID, clean(t.Taker), stamp(t.LaunchedAt), t.EventIDs) } fmt.Fprintf(w, " Indeterminate %d lifecycle messages wait for a person\n", len(s.Indeterminate)) for _, in := range s.Indeterminate { @@ -377,7 +392,7 @@ and who authorized it is recorded. A completed or held record is admitted at once (a completed one whose task is still running, when that task ends). A blocked record keeps its state and -runs what blocked it again — the read, the events lookup, the route check — +runs what blocked it again — the read, the events lookup, the served check — and is admitted the moment that succeeds; if it blocks again, the record stays blocked with the authorization, and redispatch runs it again. While the hold stands the record is authorized and nothing launches until release. @@ -431,7 +446,12 @@ func runConnectRedispatch(cmd *cobra.Command, raw string) error { } defer done() - res, err := ledger.Redispatch(ctx, id, operatorName()) + // The served projects as this command read them, rather than the bit on + // the record: a record admitted while its project was served is not + // authorization to run it after the operator stopped serving it. Read at + // start-up and not re-read here, so an unserve landing in between is not + // caught — see servedBucketsOf. + res, err := ledger.Redispatch(ctx, id, operatorName(), servedBucketsOf(p.file)) if err != nil { return decisionError(err) } diff --git a/internal/commands/connect_operator_test.go b/internal/commands/connect_operator_test.go index 0bb4d6312..563c5d9a8 100644 --- a/internal/commands/connect_operator_test.go +++ b/internal/commands/connect_operator_test.go @@ -72,7 +72,7 @@ func (f operatorFixture) ledger(t *testing.T, shadow bool) *connector.Ledger { _, err = l.Admission().Commit(ctx, admission.Verdict{ EventID: 1, EventType: "comment.created", BucketID: setupProject, RecordingID: 77, RequesterID: setupOperatorPerson, State: admission.StateAdmitted, Trigger: admission.TriggerMentioned, Acknowledge: true, ConversationKey: "recording:70", - Reply: &admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: 70}, Routed: true, Route: "/work/app", + Reply: &admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: 70}, Served: true, RecordingURL: "https://app.basecamp.com/999/buckets/1/recordings/77", Snapshot: &admission.Snapshot{Type: "Comment", Content: operatorSecretContent, UpdatedAt: time.Now()}, }) @@ -536,26 +536,28 @@ func preflightCheck(t *testing.T, checks []setup.Check) (setup.Check, bool) { return setup.Check{}, false } -// codexProfile is an acp/codex profile whose routes are dirs under one root, -// with HOME (and so ~/.codex) pointed at a home of its own: the machine -// state the Codex preflight reads, and nothing of the person running the -// test. -func codexProfile(t *testing.T, routes int) (setup.File, string, []string) { +// codexProfile is an acp/codex profile, with HOME (and so ~/.codex) pointed +// at a home of its own and the process's working directory at a repository +// of its own: the machine state the Codex preflight reads, and nothing of +// the person running the test. Doctor checks the directory it runs in, so +// the test runs in one it owns. +func codexProfile(t *testing.T) (file setup.File, home, dir string) { t.Helper() - home := t.TempDir() + home = t.TempDir() t.Setenv("HOME", home) t.Setenv("CODEX_HOME", "") - file := setup.New("agent") + dir = filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + t.Chdir(dir) + // Doctor names the directory as the kernel reports it, which on a Mac is + // the resolved path under /private. + resolved, err := os.Getwd() + require.NoError(t, err) + file = setup.New("agent") file.Driver = setup.DriverACP file.Worker = setup.WorkerCodex - paths := make([]string, 0, routes) - for i := range routes { - dir := filepath.Join(t.TempDir(), "repo") - require.NoError(t, os.MkdirAll(dir, 0o755)) - file.Projects[int64(i+1)] = admission.Route{Path: dir} - paths = append(paths, dir) - } - return file, home, paths + file.Projects[1] = admission.Project{} + return file, home, resolved } func writeCodexConfig(t *testing.T, dir, body string) string { @@ -571,25 +573,30 @@ func writeCodexConfig(t *testing.T, dir, body string) string { // machine is blocked with a notice on the card. Doctor runs that same // refusal: a profile whose adapter is installed and on the pin is not ready // if no session it would start could run. -func TestConnectDoctorRunsTheAdaptersPreflightAgainstEveryRoutedDirectory(t *testing.T) { - file, home, _ := codexProfile(t, 2) +// +// It runs it in the directory doctor itself was started in. That is where a +// dispatch would run a session only if the connector was started in the same +// place — the connector runs where it is started and records nowhere — so +// the check names the directory it checked. +func TestConnectDoctorRunsTheAdaptersPreflightInItsOwnDirectory(t *testing.T) { + file, home, dir := codexProfile(t) checks := workerBinaryChecks(file) c, ok := preflightCheck(t, checks) require.True(t, ok, "the codex adapter's preflight is a check of its own") assert.Equal(t, setup.StatusPass, c.Status) - assert.Contains(t, c.Message, "2 checked", "it says how many routed directories it ran in") + assert.Contains(t, c.Message, dir, "it says which directory it ran in") - // The user's own layer: shared by every route, and the one anybody who - // uses Codex with MCP servers at all has. + // The user's own layer: the one anybody who uses Codex with MCP servers + // at all has. userConfig := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") c, ok = preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status, "doctor never calls a profile ready that would not start") assert.Contains(t, c.Message, userConfig, "the person reading this has to know which file") - assert.Contains(t, c.Message, "any routed directory", "one reason every route shares is reported once") - assert.Equal(t, 1, strings.Count(c.Message, userConfig), "and named once, not once per route") + assert.Contains(t, c.Message, dir, "and which directory it was checked in") + assert.Equal(t, 1, strings.Count(c.Message, userConfig), "and named once") assert.Contains(t, c.Hint, "CODEX_HOME", "and what to do about it") // And that is what the command exits with: the file is on the error a @@ -599,35 +606,6 @@ func TestConnectDoctorRunsTheAdaptersPreflightAgainstEveryRoutedDirectory(t *tes assert.Contains(t, err.Error(), userConfig) } -// One route can carry a project layer another has not, so the check runs in -// every routed directory and reports every route that would not start, not -// the first. -func TestConnectDoctorPreflightNamesEveryRouteThatWouldNotStart(t *testing.T) { - file, _, paths := codexProfile(t, 3) - writeCodexConfig(t, paths[1], "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - writeCodexConfig(t, paths[2], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") - - c, ok := preflightCheck(t, workerBinaryChecks(file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status) - assert.Contains(t, c.Message, filepath.Join(paths[1], ".codex", "config.toml")) - assert.Contains(t, c.Message, filepath.Join(paths[2], ".codex", "config.toml"), - "the second route's own layer is reported too, not only the first's") - assert.NotContains(t, c.Message, "any routed directory", "the clean route is not called blocked") - assert.NotContains(t, c.Message, filepath.Join(paths[0], ".codex"), "the route that would start is not named") -} - -// With no route there is no directory a session would run in, and nothing -// dispatches anyway: the check says so rather than passing on a machine it -// never looked at. -func TestConnectDoctorPreflightSkipsWithNoRoute(t *testing.T) { - file, _, _ := codexProfile(t, 0) - c, ok := preflightCheck(t, workerBinaryChecks(file)) - require.True(t, ok) - assert.Equal(t, setup.StatusSkip, c.Status) - assert.Contains(t, c.Message, "No project is routed") -} - // claude-agent-acp has nothing on this machine to refuse a session over, so // it gets no row: a check that does not exist must not report that it // passed. @@ -635,7 +613,7 @@ func TestConnectDoctorHasNoPreflightRowForAnAdapterWithoutOne(t *testing.T) { file := setup.New("agent") file.Driver = setup.DriverACP file.Worker = setup.WorkerClaude - file.Projects[1] = admission.Route{Path: t.TempDir()} + file.Projects[1] = admission.Project{} _, ok := preflightCheck(t, workerBinaryChecks(file)) assert.False(t, ok) } @@ -648,25 +626,11 @@ func TestConnectDoctorHasNoPreflightRowUnderTheSpawnDriver(t *testing.T) { file := setup.New("agent") file.Driver = setup.DriverSpawn file.Worker = setup.WorkerCodex - file.Projects[1] = admission.Route{Path: t.TempDir()} + file.Projects[1] = admission.Project{} _, ok := preflightCheck(t, workerBinaryChecks(file)) assert.False(t, ok) } -// With one route there is no "every route" to speak of: the message names -// the directory, because a person with one route reads the path, not a -// quantifier over it. -func TestConnectDoctorPreflightNamesTheOnlyRoute(t *testing.T) { - file, home, paths := codexProfile(t, 1) - writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - - c, ok := preflightCheck(t, workerBinaryChecks(file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status) - assert.Contains(t, c.Message, paths[0]) - assert.NotContains(t, c.Message, "any routed directory") -} - // A config layer that cannot be read refuses every session too — nothing // can say it declares no MCP server — but it is not a declaration, and // telling a person to take mcp_servers out of a file they cannot read is @@ -675,7 +639,7 @@ func TestConnectDoctorPreflightSaysWhatToDoAboutAnUnreadableConfig(t *testing.T) if os.Geteuid() == 0 { t.Skip("root reads a file whatever its mode says") } - file, home, _ := codexProfile(t, 1) + file, home, _ := codexProfile(t) config := writeCodexConfig(t, home, "model = \"gpt-5\"\n") require.NoError(t, os.Chmod(config, 0o000)) t.Cleanup(func() { _ = os.Chmod(config, 0o600) }) @@ -693,37 +657,19 @@ func TestConnectDoctorPreflightSaysWhatToDoAboutAnUnreadableConfig(t *testing.T) } // And one run names every layer a person has to change, not the first: the -// shared layers are read before a route's own, so stopping at the first -// would hide the route's until the shared one was fixed and doctor run +// user's layer is read before the directory's own, so stopping at the first +// would hide the directory's until the user's was fixed and doctor run // again. func TestConnectDoctorPreflightNamesEveryLayerInOneRun(t *testing.T) { - file, home, paths := codexProfile(t, 1) + file, home, dir := codexProfile(t) user := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - project := writeCodexConfig(t, paths[0], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") + project := writeCodexConfig(t, dir, "[mcp_servers.other]\ncommand = \"other-mcp\"\n") c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Contains(t, c.Message, user) - assert.Contains(t, c.Message, project, "the route's own layer is named in the same run as the shared one") -} - -// A layer every route shares is reported once for all of them even when one -// route has a second reason of its own: they are grouped one refusal at a -// time, not by the whole of what a route was refused for. -func TestConnectDoctorPreflightGroupsEachRefusalOnItsOwn(t *testing.T) { - file, home, paths := codexProfile(t, 2) - user := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - project := writeCodexConfig(t, paths[1], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") - - c, ok := preflightCheck(t, workerBinaryChecks(file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status) - assert.Equal(t, 1, strings.Count(c.Message, user), "the shared layer is named once") - assert.Contains(t, c.Message, "any routed directory", "and named as every route's") - assert.Equal(t, 1, strings.Count(c.Message, project), "the second route's own layer is named too, once") - assert.Contains(t, c.Message, "start in "+paths[1]+":", "and named as that route's alone") - assert.NotContains(t, c.Message, "start in "+paths[0], "the route with only the shared reason is not named on its own") + assert.Contains(t, c.Message, project, "the directory's own layer is named in the same run as the user's") } // The hint carries what to do about every reason reported, most pressing @@ -734,7 +680,7 @@ func TestConnectDoctorPreflightHintsAtWhatMostNeedsDoing(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root reads a file whatever its mode says") } - file, home, _ := codexProfile(t, 1) + file, home, _ := codexProfile(t) // config.toml is read before managed_config.toml, so the layer that // cannot be read is the one this would hint about by order alone. unreadable := writeCodexConfig(t, home, "model = \"gpt-5\"\n") diff --git a/internal/commands/connect_run.go b/internal/commands/connect_run.go index 8d418735b..d70878c8b 100644 --- a/internal/commands/connect_run.go +++ b/internal/commands/connect_run.go @@ -315,8 +315,28 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { return err } + // connect.json's served projects as they are now, for admission and for + // dispatch alike. What one reader buys is that neither half reads the + // startup file any more: both reload from the same place, share one + // cache, and treat a failed read the same way. Admission deciding + // against the file as it was at startup while the dispatcher read the + // current one is what left an unserved project's events admitted and + // never started — no work and no holding reply — and a newly served + // project's blocked until a restart (Copilot on #765). + // + // It is not a shared snapshot, and the sentence above is not saying it + // is. Admission and dispatch call Current independently, and the cache + // can expire between the two calls, so a setup change landing in that + // gap is seen by one and not the other (Copilot on #765). The + // difference from the bug this replaced is that the disagreement is + // bounded: one decision against a served set at most connectServedTTL + // old, where the startup file never caught up at all. Holding one + // snapshot across a whole decision needs a lock over setup, which is + // carded rather than done here. + served := newConnectServed(path, file, logger) + reads := admission.NewSDKReads(&basecamp.Config{BaseURL: app.Config.BaseURL}, tokens, account, connectSDKOptions()...) - admitter, err := admission.NewAdmitter(policy, reads) + admitter, err := admission.NewAdmitter(policy, reads, admission.WithServed(served.Current)) if err != nil { return output.ErrUsage(err.Error()) } @@ -347,13 +367,12 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { if err != nil { return err } - routes := newConnectRoutes(path, file, logger) worker, err := connectDriver(driverName, file.WorkerName(), f.adapters) if err != nil { return output.ErrUsage(err.Error()) } options := connectDispatcherOptions(connectDispatch{ - File: file, Buckets: buckets, Ledger: ledger, Driver: worker, Routes: routes.Current, + File: file, Buckets: buckets, Ledger: ledger, Driver: worker, Served: served.Current, Profile: name, Executable: exe, StateDir: stateDir, SessionsDir: sessions, // Replies are listed with their words, so the connector's own // notices are left out even before their receipts are known, and @@ -538,11 +557,11 @@ func connectUnsupportedOSError(goos string) error { return output.ErrUsage(fmt.Sprintf("basecamp connect runs on Linux only, not %s: %s", goos, connectLinuxOnlyReason)) } -// connectRoutes is connect.json's routes as they are now, not as they were at -// start: a route removed by `connect setup --unroute` stops authorizing -// dispatch without a restart. A file that no longer loads, or that now names -// another agent or account, authorizes nothing. -type connectRoutes struct { +// connectServed is connect.json's served projects as they are now, not as +// they were at start: a project removed by `connect setup --unserve` stops +// authorizing dispatch without a restart. A file that no longer loads, or +// that now names another agent or account, authorizes nothing. +type connectServed struct { path string agent setup.Agent account string @@ -550,32 +569,50 @@ type connectRoutes struct { now func() time.Time mu sync.Mutex loadedAt time.Time - routes map[int64]admission.Route - failing bool + projects map[int64]admission.Project + // err is why the last reload could not answer. It is kept apart from an + // empty map on purpose: "the operator serves no projects" and "nothing + // could read the file" are different answers, and only the first is + // safe to tell a person on a card (Copilot on #765). + err error + failing bool } -// connectRoutesTTL is how long a read of connect.json is reused. -const connectRoutesTTL = 2 * time.Second +// connectServedTTL is how long a read of connect.json is reused. +const connectServedTTL = 2 * time.Second -func newConnectRoutes(path string, file setup.File, log *slog.Logger) *connectRoutes { - return &connectRoutes{path: path, agent: file.Agent, account: file.AccountID, log: log, now: time.Now} +func newConnectServed(path string, file setup.File, log *slog.Logger) *connectServed { + return &connectServed{path: path, agent: file.Agent, account: file.AccountID, log: log, now: time.Now} } -// Current returns a copy of the routes connect.json approves now. -func (r *connectRoutes) Current() map[int64]admission.Route { +// Current returns a copy of the projects connect.json serves as of the last +// read, or the reason it could not be read. "As of the last read" is the +// honest span: a reading is reused for connectServedTTL, and nothing holds +// the setup lock, so a `connect setup --unserve` can complete between any +// read and whatever the caller goes on to do with it. Dispatch treats an error as authorizing +// nothing; admission holds the record as a configuration error rather than +// answering that the project is not served. +func (r *connectServed) Current() (map[int64]admission.Project, error) { r.mu.Lock() defer r.mu.Unlock() - if r.routes == nil || r.now().Sub(r.loadedAt) >= connectRoutesTTL { + // A failure is cached for the TTL exactly as an answer is. Reloading on + // every call while the file is broken would read it once per event in + // admission and once per tick in dispatch (Copilot on #765); the answer + // would not change, and the log line is written once either way. + if (r.projects == nil && r.err == nil) || r.now().Sub(r.loadedAt) >= connectServedTTL { r.reload() } - out := make(map[int64]admission.Route, len(r.routes)) - for k, v := range r.routes { + if r.err != nil { + return nil, r.err + } + out := make(map[int64]admission.Project, len(r.projects)) + for k, v := range r.projects { out[k] = v } - return out + return out, nil } -func (r *connectRoutes) reload() { +func (r *connectServed) reload() { r.loadedAt = r.now() file, err := setup.Load(r.path) switch { @@ -589,16 +626,17 @@ func (r *connectRoutes) reload() { r.log.Error("connector: dispatching nothing until connect.json is usable again", "error", err) } r.failing = true - r.routes = map[int64]admission.Route{} + r.projects, r.err = nil, err return } if r.failing { r.log.Info("connector: connect.json is usable again") } r.failing = false - r.routes = make(map[int64]admission.Route, len(file.Projects)) - for bucket, route := range file.Projects { - r.routes[bucket] = route + r.err = nil + r.projects = make(map[int64]admission.Project, len(file.Projects)) + for bucket, project := range file.Projects { + r.projects[bucket] = project } } @@ -608,7 +646,7 @@ type connectDispatch struct { Buckets []int64 Ledger *connector.Ledger Driver driver.Driver - Routes func() map[int64]admission.Route + Served func() (map[int64]admission.Project, error) Profile string Executable string @@ -628,7 +666,7 @@ func connectDispatcherOptions(d connectDispatch) connector.DispatcherOptions { return connector.DispatcherOptions{ Ledger: d.Ledger, Driver: d.Driver, - Routes: d.Routes, + Served: d.Served, Concurrency: d.File.Concurrency, Deadline: time.Duration(d.File.Deadline), Buckets: d.Buckets, diff --git a/internal/commands/connect_run_test.go b/internal/commands/connect_run_test.go index d5ada8d80..37a058e9d 100644 --- a/internal/commands/connect_run_test.go +++ b/internal/commands/connect_run_test.go @@ -59,7 +59,7 @@ func TestConnectRunsOnLinuxOnly(t *testing.T) { } // Copilot: dispatch authorization follows connect.json as it is now. -func TestConnectRoutesFollowConnectJSON(t *testing.T) { +func TestServedProjectsFollowConnectJSON(t *testing.T) { dir := filepath.Join(t.TempDir(), "connect") require.NoError(t, os.Mkdir(dir, 0o700)) path := filepath.Join(dir, "connect.json") @@ -67,7 +67,7 @@ func TestConnectRoutesFollowConnectJSON(t *testing.T) { file.AccountID = "2914079" file.Agent = setup.Agent{PersonID: 52007412, Kind: setup.KindAgent} file.Trust.OperatorID = 26909558 - file.Projects = map[int64]admission.Route{48929974: {Path: "/work/repo"}} + file.Projects = map[int64]admission.Project{48929974: {Class: "internal"}} write := func(f setup.File) { data, err := json.Marshal(f) require.NoError(t, err) @@ -76,25 +76,62 @@ func TestConnectRoutesFollowConnectJSON(t *testing.T) { write(file) clock := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) - routes := newConnectRoutes(path, file, slog.New(slog.DiscardHandler)) - routes.now = func() time.Time { return clock } - assert.Equal(t, "/work/repo", routes.Current()[48929974].Path) - - unrouted := file - unrouted.Projects = map[int64]admission.Route{} - write(unrouted) - clock = clock.Add(connectRoutesTTL) - assert.Empty(t, routes.Current(), "an unrouted project stops authorizing dispatch without a restart") - - other := file - other.Agent.PersonID = 1 - write(other) - clock = clock.Add(connectRoutesTTL) - assert.Empty(t, routes.Current(), "a file naming another agent authorizes nothing") - - require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) - clock = clock.Add(connectRoutesTTL) - assert.Empty(t, routes.Current(), "a file that no longer loads authorizes nothing") + served := newConnectServed(path, file, slog.New(slog.DiscardHandler)) + served.now = func() time.Time { return clock } + current, err := served.Current() + require.NoError(t, err) + require.Contains(t, current, int64(48929974)) + assert.Equal(t, "internal", current[48929974].Class) + + unserved := file + unserved.Projects = map[int64]admission.Project{} + write(unserved) + clock = clock.Add(connectServedTTL) + current, err = served.Current() + require.NoError(t, err, "serving nothing is an answer, not a failure") + assert.Empty(t, current, "a project no longer served stops authorizing dispatch without a restart") + + // Each failure is checked from a *non-empty* last-good state, and on the + // map that failing call returned — not on one a previous call left in + // the variable. Asserting the stale one passes however much + // authorization a broken read hands back, which is the one place in this + // change where that would cost the most (Copilot on #765). + for _, tc := range []struct { + name string + break_ func() + why string + }{ + { + name: "a file naming another agent", + break_: func() { + other := file + other.Agent.PersonID = 1 + write(other) + }, + why: "a file naming another agent is a failure to read the answer, not the answer", + }, + { + name: "a file that no longer parses", + break_: func() { require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) }, + why: "a file that no longer loads is reported as unreadable, never as an empty served set", + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Back to serving something, so a leak of stale authorization + // has something to leak. + write(file) + clock = clock.Add(connectServedTTL) + good, err := served.Current() + require.NoError(t, err) + require.NotEmpty(t, good, "the last good read served a project") + + tc.break_() + clock = clock.Add(connectServedTTL) + broken, err := served.Current() + assert.Error(t, err, tc.why) + assert.Empty(t, broken, "and it hands back no authorization at all, stale or otherwise") + }) + } } // Copilot and review r2: the run's --project scope reaches the dispatcher. @@ -174,7 +211,7 @@ func TestTheDoctorCheckReadsTheProfilesConnectorLayout(t *testing.T) { file.AccountID = "2914079" file.Agent = setup.Agent{PersonID: 52007412, Kind: setup.KindAgent} file.Trust.OperatorID = 26909558 - file.Projects = map[int64]admission.Route{48929974: {Path: "/work/repo"}} + file.Projects = map[int64]admission.Project{48929974: {}} path, err := setup.Path(config.GlobalConfigDir(), "agent") require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) diff --git a/internal/commands/connect_setup_test.go b/internal/commands/connect_setup_test.go index 26017b9ec..961ac58d2 100644 --- a/internal/commands/connect_setup_test.go +++ b/internal/commands/connect_setup_test.go @@ -288,16 +288,15 @@ func storeConnectProfileScoped(t *testing.T, s *connectSetupServer, name, token, require.NoError(t, mgr.ImportToken(context.Background(), token, scope, "", "", time.Now().Add(24*time.Hour))) } -// routeArg routes the test project to a fresh directory. -func routeArg(t *testing.T) string { - t.Helper() - return fmt.Sprintf("--route=%d=%s", setupProject, t.TempDir()) +// serveArg serves the test project. +func serveArg() string { + return fmt.Sprintf("--serve=%d", setupProject) } // firstSetup runs a successful first setup of the agent profile. func firstSetup(t *testing.T, s *connectSetupServer) { t.Helper() - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) } @@ -315,16 +314,15 @@ func connectSetupPath(t *testing.T, profile string) string { } // On a profile connected to the agent, setup leaves a connect.json with a -// routed project that admission reads, and passing token, identity and mint +// served project that admission reads, and passing token, identity and mint // checks. func TestConnectSetupOnAConnectedProfile(t *testing.T) { s := startConnectSetupServer(t) app := connectSetupApp(t, s, "agent") - repo := t.TempDir() out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), - "--route", fmt.Sprintf("%d=%s", setupProject, repo), + "--serve", fmt.Sprint(setupProject), "--watch-completions", fmt.Sprint(setupProject), "--class", fmt.Sprintf("%d=internal", setupProject)) require.NoError(t, err, out) @@ -343,9 +341,7 @@ func TestConnectSetupOnAConnectedProfile(t *testing.T) { p.AgentID = setupAgentPerson require.NoError(t, p.Validate()) assert.Equal(t, setupOperatorPerson, p.Trust.OperatorID) - realRepo, err := filepath.EvalSymlinks(repo) - require.NoError(t, err) - assert.Equal(t, admission.Route{Path: realRepo, Class: "internal", WatchCompletions: true}, p.Projects[setupProject]) + assert.Equal(t, admission.Project{Class: "internal", WatchCompletions: true}, p.Projects[setupProject]) f, err := setup.Load(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -374,7 +370,7 @@ func TestConnectSetupNamesTheAgentReadRefusal(t *testing.T) { s.refuseAgentReads = true app := connectSetupApp(t, s, "agent") - out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -385,7 +381,7 @@ func TestConnectSetupNamesTheAgentReadRefusal(t *testing.T) { assertNotWritten(t, "agent") } -func TestConnectSetupWithNoRouteIsNotReady(t *testing.T) { +func TestConnectSetupWithNoServedProjectIsNotReady(t *testing.T) { s := startConnectSetupServer(t) out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson)) require.Error(t, err, out) @@ -395,6 +391,37 @@ func TestConnectSetupWithNoRouteIsNotReady(t *testing.T) { } // Bad input is refused before anything is read or written. +// Copilot on #765: the operator can turn the agent off through the interface +// that turned it on. +// +// An explicit --unserve of the last project produces a valid empty file, and +// the readiness check used to refuse to write it — so the only way to +// withdraw the agent's last authorization was to edit or delete connect.json +// by hand, which is the trust anchor. It is written now, with a warning that +// says plainly what it leaves behind. +func TestConnectSetupCanWithdrawTheLastServedProject(t *testing.T) { + s := startConnectSetupServer(t) + firstSetup(t, s) + + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--unserve", fmt.Sprint(setupProject)) + require.NoError(t, err, out) + + f, err := setup.Load(connectSetupPath(t, "agent")) + require.NoError(t, err) + assert.Empty(t, f.Projects, "the withdrawal is on disk, not just reported") + + // And the operator is told what they are now left with. + assert.Contains(t, out, "no work at all") + + // A first setup still has to serve something: that is the other question. + require.NoError(t, os.Remove(connectSetupPath(t, "agent"))) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson)) + require.Error(t, err, out) + var apiErr *output.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, codeNotReady, apiErr.Code) +} + func TestConnectSetupRefusesBadInput(t *testing.T) { op := fmt.Sprint(setupOperatorPerson) wantMessage := map[string]string{ @@ -404,20 +431,20 @@ func TestConnectSetupRefusesBadInput(t *testing.T) { "expect-identity on agent": "holds an Agent's credential", } for name, args := range map[string][]string{ - "missing route directory": {"--operator", op, "--route", fmt.Sprintf("%d=/does/not/exist", setupProject)}, - "class without a route": {"--operator", op, "--class", fmt.Sprintf("%d=internal", setupProject)}, - "bad trust mode": {"--operator", op, "--trust", "domain"}, - "allow outside allowlist": {"--operator", op, "--trust", "project", "--allow", "7"}, - "bad driver": {"--operator", op, "--driver", "fork"}, - "bad concurrency": {"--operator", op, "--concurrency", "100"}, - "zero concurrency": {"--operator", op, "--concurrency", "0"}, - "bad deadline": {"--operator", op, "--deadline", "5s"}, - "zero deadline": {"--operator", op, "--deadline", "0"}, - "malformed route": {"--operator", op, "--route", "not-a-pair"}, - "no operator": {"--route", fmt.Sprintf("%d=%s", setupProject, os.TempDir())}, - "missing operator profile": {"--operator-profile", "nobody"}, - "operator profile unlogged": {"--operator-profile", "unlogged"}, - "expect-identity on agent": {"--operator", op, "--expect-identity", "4242"}, + "class without a served project": {"--operator", op, "--class", fmt.Sprintf("%d=internal", setupProject)}, + "bad trust mode": {"--operator", op, "--trust", "domain"}, + "allow outside allowlist": {"--operator", op, "--trust", "project", "--allow", "7"}, + "bad driver": {"--operator", op, "--driver", "fork"}, + "bad concurrency": {"--operator", op, "--concurrency", "100"}, + "zero concurrency": {"--operator", op, "--concurrency", "0"}, + "bad deadline": {"--operator", op, "--deadline", "5s"}, + "zero deadline": {"--operator", op, "--deadline", "0"}, + "malformed serve": {"--operator", op, "--serve", "not-a-number"}, + "zero serve": {"--operator", op, "--serve", "0"}, + "no operator": {serveArg()}, + "missing operator profile": {"--operator-profile", "nobody"}, + "operator profile unlogged": {"--operator-profile", "unlogged"}, + "expect-identity on agent": {"--operator", op, "--expect-identity", "4242"}, } { t.Run(name, func(t *testing.T) { s := startConnectSetupServer(t) @@ -442,7 +469,7 @@ func TestConnectSetupRefusesAProfileWithNoCredential(t *testing.T) { _, err := registerProfile("agent", &config.ProfileConfig{BaseURL: s.srv.URL, AccountID: "999"}) require.NoError(t, err) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -451,7 +478,7 @@ func TestConnectSetupRefusesAProfileWithNoCredential(t *testing.T) { assert.Zero(t, s.intakeCount(), "setup never starts a connection") assertNotWritten(t, "agent") - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "4242", routeArg(t)) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "4242", serveArg()) require.Error(t, err, out) require.ErrorAs(t, err, &apiErr) assert.Contains(t, apiErr.Hint, "basecamp auth login -P agent --expect-identity 4242") @@ -467,7 +494,7 @@ func TestConnectSetupWorksInTheProfilesOwnAccount(t *testing.T) { app := newConnectSetupApp(t, s, "agent") app.Config.AccountID = "1000" // a config-wide default, not the profile's app.Config.Sources["account_id"] = string(config.SourceGlobal) - out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) f, err := setup.Load(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -486,7 +513,7 @@ func TestConnectSetupRefusesAZeroPersonID(t *testing.T) { s := startConnectSetupServer(t) connectSetupApp(t, s, "agent") s.agentID = 0 - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -499,7 +526,7 @@ func TestConnectSetupRefusesAZeroPersonID(t *testing.T) { // with =. func TestConnectSetupClearsAClass(t *testing.T) { s := startConnectSetupServer(t) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t), + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg(), "--class", fmt.Sprintf("%d=internal", setupProject)) require.NoError(t, err, out) @@ -534,7 +561,7 @@ func TestConnectSetupNeverChangesTheCredential(t *testing.T) { connectSetupApp(t, s, "agent") before := credential(t) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -603,7 +630,7 @@ func TestConnectSetupRefusesAnotherAccountUnderTheSameProfile(t *testing.T) { func TestConnectSetupRefusesTheAgentAsItsOwnOperator(t *testing.T) { s := startConnectSetupServer(t) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupAgentPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupAgentPerson), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "never authorizes", "refused by setup, before the file layer's own refusal") assertNotWritten(t, "agent") @@ -614,13 +641,13 @@ func TestConnectSetupRefusesTheAgentAsItsOwnOperator(t *testing.T) { // written. func TestConnectSetupVerifiesTheOperatorBeforeWriting(t *testing.T) { s := startConnectSetupServer(t) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupClientPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupClientPerson), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "client") assertNotWritten(t, "agent") s.refusePeople = true - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "cannot be verified") assertNotWritten(t, "agent") @@ -635,7 +662,7 @@ func TestConnectSetupResolvesTheOperatorFromTheirProfile(t *testing.T) { storeConnectProfile(t, s, "me", setupOperatorToken) app := newConnectSetupApp(t, s, "agent") - out, err := runConnectSetupCmd(t, app, "--operator-profile", "me", routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator-profile", "me", serveArg()) require.NoError(t, err, out) f, err := setup.Load(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -650,12 +677,12 @@ func TestConnectSetupOnTheBotUserPath(t *testing.T) { connectSetupApp(t, s, "bot") storeConnectProfile(t, s, "bot", setupBotToken) - _, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + _, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, "a person's login without --expect-identity could be the operator's own") assert.Contains(t, err.Error(), "a person's login, not an Agent's credential") assertNotWritten(t, "bot") - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "1", routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "1", serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "not the 1 --expect-identity names") assertNotWritten(t, "bot") @@ -663,7 +690,7 @@ func TestConnectSetupOnTheBotUserPath(t *testing.T) { out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), - routeArg(t)) + serveArg()) require.NoError(t, err, out) assert.NotContains(t, out, setupTicket) f, err := setup.Load(connectSetupPath(t, "bot")) @@ -682,7 +709,7 @@ func TestConnectSetupReadOnlyCredentialIsNotReady(t *testing.T) { storeConnectProfileScoped(t, s, "bot", setupBotToken, "read") out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), - "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), routeArg(t)) + "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -695,7 +722,7 @@ func TestConnectSetupRefusesExpectIdentityForAnAgent(t *testing.T) { app := connectSetupApp(t, s, "agent") // The credential alone says it: an Agent has no identity to pin. - out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "4242", routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", "4242", serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "holds an Agent's credential") assert.Contains(t, err.Error(), "Drop --expect-identity") @@ -703,7 +730,7 @@ func TestConnectSetupRefusesExpectIdentityForAnAgent(t *testing.T) { // And once connect.json says it, the file is what the remediation // talks about, since a bot login would not satisfy it either. - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) before, err := os.ReadFile(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -748,7 +775,7 @@ func TestConnectSetupConflictsNameACommandToRun(t *testing.T) { s := startConnectSetupServer(t) bareSetupApp(t, s, "agent") tc.prepare(t, s) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), append(tc.args, "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t))...) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), append(tc.args, "--operator", fmt.Sprint(setupOperatorPerson), serveArg())...) require.Error(t, err, out) assert.Contains(t, err.Error(), tc.want) assertNotWritten(t, "agent") @@ -789,7 +816,7 @@ func TestConnectSetupOperatorProfileFollowsEnvironmentPrecedence(t *testing.T) { t.Setenv("BASECAMP_BASE_URL", s.srv.URL) } - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", serveArg()) if tc.refused { require.Error(t, err, out) assert.Contains(t, err.Error(), "is on") @@ -811,7 +838,7 @@ func TestConnectSetupReportsAnotherSetupAsBusy(t *testing.T) { require.NoError(t, err) t.Cleanup(unlock) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -830,7 +857,7 @@ func TestConnectSetupReportsACredentialRemovedMidRunAsAuth(t *testing.T) { require.NoError(t, auth.NewStore(config.GlobalConfigDir()).Delete(auth.NewManager(cfg, nil).CredentialKey())) } - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -878,7 +905,7 @@ func TestConnectSetupRefusesAnUnreadableCredentialStore(t *testing.T) { require.NoError(t, os.MkdirAll(config.GlobalConfigDir(), 0o700)) require.NoError(t, os.WriteFile(filepath.Join(config.GlobalConfigDir(), "credentials.json"), []byte("{not json"), 0o600)) - out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) assertNotWritten(t, "agent") } @@ -891,10 +918,10 @@ func TestConnectSetupKeepsARecordedOperatorTheAgentCannotRead(t *testing.T) { connectSetupApp(t, s, "agent") storeConnectProfile(t, s, "me", setupOperatorToken) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", serveArg()) require.NoError(t, err, out) - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), routeArg(t)) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), serveArg()) require.NoError(t, err, out) assert.Contains(t, out, "could not be re-read") f, err := setup.Load(connectSetupPath(t, "agent")) @@ -919,7 +946,7 @@ func TestConnectSetupVerifiesTheAllowlistBeforeWriting(t *testing.T) { connectSetupApp(t, s, "agent") storeConnectProfile(t, s, "me", setupOperatorToken) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--allow", fmt.Sprint(id), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--allow", fmt.Sprint(id), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), fmt.Sprintf("Allowlist %d", id)) assertNotWritten(t, "agent") @@ -929,7 +956,7 @@ func TestConnectSetupVerifiesTheAllowlistBeforeWriting(t *testing.T) { s := startConnectSetupServer(t) connectSetupApp(t, s, "agent") storeConnectProfile(t, s, "me", setupOperatorToken) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--allow", fmt.Sprint(setupOperatorPerson+1), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--allow", fmt.Sprint(setupOperatorPerson+1), serveArg()) require.NoError(t, err, out) f, err := setup.Load(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -950,7 +977,7 @@ func TestConnectSetupReadsTheGrantedScopeNotTheProfiles(t *testing.T) { require.NoError(t, mgr.ImportToken(context.Background(), setupBotToken, "read", "", "", time.Now().Add(24*time.Hour))) // granted read out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), - "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), routeArg(t)) + "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -975,7 +1002,7 @@ func TestConnectSetupNeverPrintsATicketFromAFailedMint(t *testing.T) { app := newConnectSetupApp(t, s, "agent") var envelope bytes.Buffer app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: &envelope}) - out, err := runConnectSetupCmd(t, app, "--operator-profile", "me", routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator-profile", "me", serveArg()) require.Error(t, err, out) assert.NotContains(t, out, canary, "command output") @@ -1014,7 +1041,7 @@ func TestConnectSetupSanitizesThePathItPrints(t *testing.T) { require.Error(t, err, out) assert.NotContains(t, err.Error(), "evil\nFAKE") - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), append(args, routeArg(t))...) + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), append(args, serveArg())...) require.NoError(t, err, out) assert.Contains(t, out, "connect.json written") assert.NotContains(t, out, "evil\nFAKE") @@ -1027,7 +1054,7 @@ func TestConnectSetupRefusesACredentialOfAnotherKind(t *testing.T) { bareSetupApp(t, s, "agent") storeConnectProfile(t, s, "agent", setupBotToken) out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), - "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), routeArg(t)) + "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), serveArg()) require.NoError(t, err, out) before, err := os.ReadFile(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -1047,29 +1074,6 @@ func TestConnectSetupRefusesACredentialOfAnotherKind(t *testing.T) { assert.Equal(t, before, after) } -// A route kept from connect.json whose directory has gone makes a rerun not -// ready, and connect.json is left as it was. -func TestConnectSetupRechecksRetainedRoutes(t *testing.T) { - s := startConnectSetupServer(t) - repo := filepath.Join(t.TempDir(), "repo") - require.NoError(t, os.Mkdir(repo, 0o700)) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), fmt.Sprintf("--route=%d=%s", setupProject, repo)) - require.NoError(t, err, out) - before, err := os.ReadFile(connectSetupPath(t, "agent")) - require.NoError(t, err) - - require.NoError(t, os.Remove(repo)) - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--concurrency", "3") - require.Error(t, err, out) - var apiErr *output.Error - require.ErrorAs(t, err, &apiErr) - assert.Equal(t, codeNotReady, apiErr.Code) - assert.Contains(t, apiErr.Message, "no longer usable") - after, err := os.ReadFile(connectSetupPath(t, "agent")) - require.NoError(t, err) - assert.Equal(t, before, after) -} - // Setup checks one credential: when another process stores a different one // under the profile while the checks run, nothing is written for either. func TestConnectSetupRefusesACredentialReplacedDuringTheChecks(t *testing.T) { @@ -1086,7 +1090,7 @@ func TestConnectSetupRefusesACredentialReplacedDuringTheChecks(t *testing.T) { } out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), - "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), routeArg(t)) + "--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -1101,7 +1105,7 @@ func TestConnectSetupRefusesACredentialReplacedDuringTheChecks(t *testing.T) { // stops instead of acting as the wrong agent. func TestConnectSetupWritesAPolicyBoundToTheCredential(t *testing.T) { s := startConnectSetupServer(t) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) f, err := setup.Load(connectSetupPath(t, "agent")) @@ -1136,7 +1140,7 @@ func TestConnectSetupRefusesWhenTheHostCannotLock(t *testing.T) { require.NoError(t, os.Chmod(locks, 0o500)) t.Cleanup(func() { _ = os.Chmod(locks, 0o700) }) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "cannot lock") var apiErr *output.Error @@ -1152,7 +1156,7 @@ func TestConnectSetupRefusesToRebindToAnotherIdentity(t *testing.T) { bareSetupApp(t, s, "bot") storeConnectProfile(t, s, "bot", setupBotToken) args := []string{"--operator", fmt.Sprint(setupOperatorPerson), "--expect-identity", fmt.Sprint(setupBotIdentity)} - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), append(args, routeArg(t))...) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), append(args, serveArg())...) require.NoError(t, err, out) before, err := os.ReadFile(connectSetupPath(t, "bot")) require.NoError(t, err) @@ -1176,7 +1180,7 @@ func TestConnectSetupBusyIsRetryable(t *testing.T) { require.NoError(t, err) t.Cleanup(unlock) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) var apiErr *output.Error require.ErrorAs(t, err, &apiErr) @@ -1187,7 +1191,7 @@ func TestConnectSetupBusyIsRetryable(t *testing.T) { // A host that cannot take the setup lock is lock_unavailable, not usage. func TestConnectSetupClassifiesAnUnlockableHost(t *testing.T) { s := startConnectSetupServer(t) - out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) // A lock file this user cannot open at all: setup must not fall back to // running without it. @@ -1211,7 +1215,7 @@ func TestConnectSetupPropagatesCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) s.duringMint = cancel - out, err := runConnectSetupCmdIn(ctx, t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmdIn(ctx, t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) assert.ErrorIs(t, err, context.Canceled) assertNotWritten(t, "agent") @@ -1266,12 +1270,12 @@ func TestConnectShowPrintsWhatSetupRecorded(t *testing.T) { var envelope struct { OK bool `json:"ok"` Data struct { - Path string `json:"path"` - Profile string `json:"profile"` - Agent setup.Agent `json:"agent"` - Trust admission.Trust `json:"trust"` - Projects map[int64]admission.Route `json:"projects"` - Deadline string `json:"deadline"` + Path string `json:"path"` + Profile string `json:"profile"` + Agent setup.Agent `json:"agent"` + Trust admission.Trust `json:"trust"` + Projects map[int64]admission.Project `json:"projects"` + Deadline string `json:"deadline"` } `json:"data"` } require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) @@ -1343,17 +1347,12 @@ func TestConnectShowRefusesAnUnsafeConnectJSON(t *testing.T) { } // A person reading show in a terminal or as Markdown sees what matters: the -// agent, the operator, the trust and every route, which the generic object -// renderer would drop, through the same output pipeline as every command. +// agent, the operator, the trust and every served project, which the generic +// object renderer would drop, through the same output pipeline as every +// command. func TestConnectShowTellsAPersonEverySetting(t *testing.T) { s := startConnectSetupServer(t) firstSetup(t, s) - f, err := setup.Load(connectSetupPath(t, "agent")) - require.NoError(t, err) - var route string - for _, r := range f.Projects { - route = r.Path - } for _, format := range []output.Format{output.FormatStyled, output.FormatMarkdown} { app := newConnectSetupApp(t, s, "agent") @@ -1366,8 +1365,8 @@ func TestConnectShowTellsAPersonEverySetting(t *testing.T) { fmt.Sprintf("person %d (agent)", setupAgentPerson), fmt.Sprintf("person %d", setupOperatorPerson), "operator", - fmt.Sprintf("Route %d", setupProject), - route, + fmt.Sprintf("Project %d", setupProject), + "1 served", "deadline 45m0s", } { assert.Contains(t, shown, want, "format %v", format) @@ -1397,24 +1396,25 @@ func TestConnectShowRefusesAnotherProfilesPolicy(t *testing.T) { assert.NotContains(t, out+buf.String(), `"projects"`) } -// A route path is a clean absolute path, which may still hold control -// characters: human output shows them escaped, never raw. -func TestConnectShowEscapesControlsInARoutePath(t *testing.T) { - path := "/home/me/Work/Q3 \x1b[31mred\u009b $launch" +// connect.json's own path is the one path show still prints, and it may hold +// control characters or what looks like markup: human output shows them +// escaped, never raw. +func TestConnectShowEscapesControlsInTheFilePath(t *testing.T) { + path := "/home/me/Q3 \x1b[31mred\u009b $launch//connect.json" f := setup.New("agent") f.AccountID = "999" f.Agent = setup.Agent{PersonID: 4001, Kind: setup.KindAgent} f.Trust.OperatorID = 1001 - f.Projects[222] = admission.Route{Path: path} for _, markdown := range []bool{false, true} { - route := connectShowDisplay("/x/connect.json", f, markdown)["route_222"].(string) - assert.NotContains(t, route, "\x1b", "markdown %v", markdown) - assert.NotContains(t, route, "\u009b", "markdown %v", markdown) - assert.Contains(t, route, `Q3 \x1b[31mred\u009b $launch`, "markdown %v", markdown) + shown := connectShowDisplay(path, f, markdown)["file"].(string) + assert.NotContains(t, shown, "\x1b", "markdown %v", markdown) + assert.NotContains(t, shown, "\u009b", "markdown %v", markdown) + assert.NotContains(t, shown, "", "markdown %v", markdown) + assert.Contains(t, shown, `Q3 \x1b[31mred\u009b $launch`, "markdown %v", markdown) } } -// The file's own path is shown as literally as the routes: a configuration +// The file's own path is shown literally: a configuration // directory holding Markdown syntax or a backslash does not render as a link // or read as an escape. func TestConnectShowShowsTheFilePathLiterally(t *testing.T) { @@ -1423,29 +1423,6 @@ func TestConnectShowShowsTheFilePathLiterally(t *testing.T) { assert.Equal(t, "`` /home/[me](x)/`cfg`/a\\\\x1b/connect.json ``", file) } -// A route path holding what looks like HTML or Markdown survives the real -// renderers: nothing in it is converted, dropped or rendered. -func TestConnectShowKeepsAPathThatLooksLikeMarkup(t *testing.T) { - s := startConnectSetupServer(t) - firstSetup(t, s) - dir := filepath.Join(t.TempDir(), "Q3 bold [x](y) `tick`") - require.NoError(t, os.Mkdir(dir, 0o700)) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--route", fmt.Sprintf("%d=%s", setupProject, dir)) - require.NoError(t, err, out) - - for format, want := range map[output.Format]string{ - output.FormatStyled: `Q3 \x3cb>bold\x3ci> [x](y) ` + "`tick`", - output.FormatMarkdown: `Q3 \x3cb>bold\x3ci> [x](y) ` + "`tick`", - } { - app := newConnectSetupApp(t, s, "agent") - var buf bytes.Buffer - app.Output = output.New(output.Options{Format: format, Writer: &buf}) - out, err := runConnectShowCmd(t, app) - require.NoError(t, err, out) - assert.Contains(t, buf.String(), want, "format %v", format) - } -} - func TestMarkdownCodeKeepsBackticksInside(t *testing.T) { assert.Equal(t, "` /a/b `", markdownCode("/a/b")) assert.Equal(t, "``` /a``b ```", markdownCode("/a``b")) diff --git a/internal/commands/connect_skilleval_test.go b/internal/commands/connect_skilleval_test.go new file mode 100644 index 000000000..87a3d8942 --- /dev/null +++ b/internal/commands/connect_skilleval_test.go @@ -0,0 +1,316 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// The connector's skill evals are checked here, against this package's own +// parser, because nothing else checks them at all: CI's Skill Evals job exits +// 0 without running a case when ANTHROPIC_API_KEY is unset, which it is on +// this repository. +// +// # What this holds, and what it does not +// +// The accept and reject patterns, and only for the shape of a --serve value +// and the removed --route flags. The mock, expect_sequence and +// accept_response patterns are read by the Ruby runner under its own regex +// semantics and are not modeled here, so a malformed one of those can still +// land without CI noticing (Copilot on #765). This is a guard on one +// invariant, not on the eval files. +// +// One of the patterns is a blunt instrument and says so: a value of all +// digits can still be too large for an int64, and no shape can see a numeric +// bound, so the rule caps a value at 18 digits. That is comfortably above any +// real project id and one short of MaxInt64's 19, which makes MaxInt64 itself +// a declared narrowing rather than a value the patterns quietly mishandle. +// +// accept was outside the claim for two rounds, honestly declared and then +// twice the source of a finding: the rejects learned to allow the ordinary +// double-quoted spelling and the accepts did not move with them, so a trace +// the CLI would take failed the eval. A gap named is still a gap, and this +// one had stopped being theoretical. +// +// # The limit, which is the shell +// +// The corpus is a list of literal command lines. The patterns are regexes +// over text. Neither models /bin/sh, and a regex over a command line cannot: +// quoting, concatenation of adjacent fragments, variable expansion and +// command substitution all change what the CLI is handed, and each has more +// spellings than a pattern can enumerate. Three rounds of review each found +// another one. +// +// So the honest claim is narrow: for the spellings in serveValues, the +// patterns and parsePositiveID agree, and every narrowing between them is +// recorded there with a reason. A command line exotic enough — an expansion, +// a substitution, a spelling nobody has thought of — can still satisfy these +// patterns and be refused by the CLI. That is a limitation of the approach, +// not a gap to be closed by adding cases, and it is written here because the +// next person extending these patterns will otherwise believe they are +// converging on completeness. +// +// What the guard does catch, and what makes it worth having: any change to +// the patterns that makes them disagree with the parser on an ordinary +// spelling, and any narrowing added without being declared. +// +// The invariant: every `connect setup` in a trace carries a --serve value +// the CLI would accept. The accepts prove a correct command was issued; the +// rejects have to prove no incorrect one was, which is the half a model can +// otherwise satisfy by issuing a command the CLI refuses and then retrying — +// the broad setup mock answers success either way. +type skillEvalCase struct { + Accept []string `yaml:"accept"` + Reject []string `yaml:"reject"` +} + +// caseProject is the project every case in this directory is about, and the +// id the corpus spells in different ways. +const caseProject = int64(222) + +// serveValues is the corpus, and choosing it is the check rather than setup +// for the check: a guard is only as strong as the inputs it asserts over. +// The first version of this omitted 0, the empty value and +222 — exactly +// the three that would have shown its rule disagreeing with the CLI in both +// directions — and the second omitted the quoted-fragment forms the shell +// joins into one word. Both gaps were found by review, not by the guard. +// +// arg is the text on the command line; value is what /bin/sh hands the CLI +// after quoting and concatenation, which is what parsePositiveID sees. +// traceOK is the decision: whether a trace may carry this spelling at all. +// Where traceOK is false and the CLI would still accept value, the rule is +// deliberately narrower than the CLI, and the test says so out loud. +var serveValues = []struct { + arg string + value string + traceOK bool + why string // only for a narrowing: why a trace may not use it +}{ + {arg: "222", value: "222", traceOK: true}, + {arg: "'222'", value: "222", traceOK: true}, + {arg: "007", value: "007", traceOK: true}, // leading zeros: the CLI reads 7 + {arg: "'007'", value: "007", traceOK: true}, // + {arg: "0", value: "0"}, // parses, not above zero + {arg: "000", value: "000"}, + {arg: "''", value: ""}, // an empty value, as a shell delivers it + {arg: "222=work", value: "222=work"}, + {arg: "222=/home/me/x", value: "222=/home/me/x"}, + {arg: "'222=work'", value: "222=work"}, + {arg: "abc", value: "abc"}, + {arg: "222abc", value: "222abc"}, + {arg: "-1", value: "-1"}, + {arg: "222,333", value: "222,333"}, + {arg: "'222'x", value: "222x"}, // the shell joins the fragments + {arg: "x'222'", value: "x222"}, + + // Double quotes are the other ordinary spelling, and the shell hands the + // CLI the same value. Omitting them is how the previous corpus let an + // undeclared narrowing through: "222" is not exotic (Copilot on #765). + {arg: `"222"`, value: "222", traceOK: true}, + {arg: `"007"`, value: "007", traceOK: true}, + {arg: `"0"`, value: "0"}, + {arg: `""`, value: ""}, + {arg: `"222=work"`, value: "222=work"}, + {arg: `"222"x`, value: "222x"}, + + // All digits and still refused: a numeric bound, which no shape-based + // pattern can see. The corpus is where a boundary like this gets + // noticed, and this one was not in it (Copilot on #765). + {arg: "9223372036854775808", value: "9223372036854775808"}, // MaxInt64 + 1 + {arg: "999999999999999999", value: "999999999999999999", traceOK: true}, + + // A well-formed id for the wrong project. traceOK is about the + // spelling, and this spelling is impeccable: every form rule in every + // case passes it, because form is all they ask about. It is here to + // prove that — the identity rule below is what catches it, and without + // this entry nothing would notice if that rule were deleted. 2223 in + // particular has 222 as a prefix, which is how a rule written with \b + // or without an anchored end would let it through (Copilot on #765). + {arg: "2223", value: "2223", traceOK: true}, + {arg: `"2223"`, value: "2223", traceOK: true}, + + // The right project, spelled with a leading zero. The CLI reads 222 and + // the identity rule refuses it, through the same {4,} branch that + // catches 2223 — so it is a narrowing, and one the identity rule + // created the moment it existed. The guard found it the way it found + // +222 and MaxInt64: by being asked (Copilot on #765). + // + // Declared rather than allowed. Letting 0*222 through means the rule + // has to say "222 with any number of leading zeros" in every case file, + // which is more pattern for a spelling nobody writes — and this branch + // already refuses a leading-zero project id in connect.json itself, in + // admission.canonicalKey, for the same reason. + {arg: "0222", value: "0222", why: "a leading zero is not how an id is written, and canonicalKey refuses the same spelling in connect.json"}, + {arg: `"0222"`, value: "0222", why: "same, quoted"}, + + // The narrowings. The CLI takes all four; a trace may not. + {arg: "+222", value: "+222", why: "a leading plus is not how an id is written"}, + {arg: "'22''2'", value: "222", why: "fragments the shell joins are not a spelling to teach"}, + {arg: "222'333'", value: "222333", why: "same, the other way round"}, + {arg: `'222'"333"`, value: "222333", why: "same, across both quote styles"}, + {arg: "9223372036854775807", value: "9223372036854775807", + why: "MaxInt64 itself: the digit cap that catches the values above it cannot spare this one, and no project id is anywhere near"}, +} + +func TestConnectSkillEvalRejectsHoldTheServeValueRule(t *testing.T) { + dir := filepath.Join("..", "..", "skill-evals", "cases", "basecamp-connect") + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.NotEmpty(t, entries) + + checked := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yml") { + continue + } + t.Run(e.Name(), func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + require.NoError(t, err) + var c skillEvalCase + require.NoError(t, yaml.Unmarshal(raw, &c)) + + compile := func(kind string, pats []string) []*regexp.Regexp { + out := make([]*regexp.Regexp, 0, len(pats)) + for _, p := range pats { + re, err := regexp.Compile(p) + require.NoError(t, err, "%s pattern %q", kind, p) + out = append(out, re) + } + return out + } + matches := func(res []*regexp.Regexp, cmd string) bool { + for _, re := range res { + if re.MatchString(cmd) { + return true + } + } + return false + } + rejects := compile("reject", c.Reject) + caught := func(cmd string) bool { return matches(rejects, cmd) } + // Form and identity are two different questions, and one set of + // rejects answers both. A rule that fires on a well-formed id + // for another project, and not on this case's own, is asking + // the identity question; everything else is asking about the + // spelling. Partitioned by what the rules do rather than by how + // they are written, so rewording one does not silently move it. + var spelling, identity []*regexp.Regexp + for _, re := range rejects { + other := re.MatchString("connect setup -P helper --serve 223 --json") + own := re.MatchString(fmt.Sprintf("connect setup -P helper --serve %d --json", caseProject)) + if other && !own { + identity = append(identity, re) + continue + } + spelling = append(spelling, re) + } + misspelled := func(cmd string) bool { return matches(spelling, cmd) } + wrongProject := func(cmd string) bool { return matches(identity, cmd) } + // Only the accepts that speak about --serve: a case also accepts + // its profile and its operator, which say nothing about a value. + serveAccepts := compile("accept", filterServe(c.Accept)) + + // A case may forbid setup outright — unconfirmed-identity does, + // because the credential is not the agent the person named. The + // invariant holds there a fortiori: no setup command may appear, + // so none carrying a bad value can. + if caught("connect setup -P helper --serve 222 --json") && + caught("connect setup -P helper --concurrency 4 --json") { + t.Log("case forbids connect setup outright; the invariant holds without a value rule") + return + } + checked++ + // Not implied by the loop below: a case whose corpus happened to + // hold only correct ids would pass every assertion with no + // identity rule at all. + require.NotEmpty(t, identity, + "this case allows connect setup, so it must say which project a --serve may name; the form rules never will") + + for _, v := range serveValues { + cmd := "connect setup -P helper --serve " + v.arg + " --json" + id, parseErr := parsePositiveID("--serve", v.value) + cliAccepts := parseErr == nil && id > 0 + + if v.traceOK { + // Only the spelling rules: an identity rule firing here + // is the point of it, not a disagreement about form. + assert.False(t, misspelled(cmd), "a trace may carry the spelling %q, so no rule about form may fire on it", cmd) + // And the identity rule decides on the id alone. This is + // the same scoping the accepts got: a case is about one + // project, so a well-formed id for another is wrong + // however well it is spelled. + if id == caseProject { + assert.False(t, wrongProject(cmd), "%q names this case's own project", cmd) + } else { + assert.True(t, wrongProject(cmd), + "%q is well-formed and names project %d, not %d: no rule about form will ever catch it, so the case has to", cmd, id, caseProject) + } + // And the accepts must recognize it — but only when the + // value names the project the case is about. An accept + // is scenario-specific: 007 is a different project, and + // a case is right not to accept the wrong one. The + // spelling is what is under test here, not the id. + if id == caseProject { + assert.True(t, matches(serveAccepts, cmd), + "a trace may carry %q, so an accept must recognize that spelling", cmd) + } + // The rule is a subset of the CLI's, never a different + // one: anything these patterns allow must be a command + // the CLI would take. + assert.True(t, cliAccepts, + "the patterns allow %q, so the CLI must accept %q — the rule may be narrower than the CLI, never wider", + cmd, v.value) + assert.Empty(t, v.why, "a spelling a trace may carry is not a narrowing") + continue + } + + assert.True(t, caught(cmd), + "a trace may not carry %q, so a reject must catch it — an eval that lets it through reports coverage it does not have", cmd) + assert.False(t, matches(serveAccepts, cmd), + "and no accept may recognize %q, or the eval would take it as the command it asked for", cmd) + if cliAccepts { + // A narrowing: the CLI would take it and a trace may + // not. Recorded with a reason, so it is a decision on + // the record rather than a disagreement nobody noticed. + assert.NotEmpty(t, v.why, + "%q is rejected here and accepted by the CLI, so the corpus must say why", v.arg) + } else { + assert.Empty(t, v.why, "the CLI refuses %q too; that is agreement, not a narrowing", v.arg) + } + } + + // A --serve with no value, in both spellings cobra refuses, and + // the flags that no longer exist. + for _, cmd := range []string{ + "connect setup -P helper --serve= --json", + "connect setup -P helper --serve", + "connect setup -P helper --serve ", + "connect setup -P helper --route 222=/home/me/x --json", + "connect setup -P helper --remove-route 222 --json", + } { + assert.True(t, caught(cmd), "must be caught: %q", cmd) + } + }) + } + assert.GreaterOrEqual(t, checked, 3, "every setup-issuing case is covered") +} + +// filterServe keeps the accept patterns that constrain a --serve value. A +// case also accepts its profile and its operator, and those say nothing +// about what a value may be. +func filterServe(pats []string) []string { + out := make([]string, 0, len(pats)) + for _, p := range pats { + if strings.Contains(p, "--serve") { + out = append(out, p) + } + } + return out +} diff --git a/internal/commands/files.go b/internal/commands/files.go index 5d13b81b9..decd18e54 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -1908,7 +1908,12 @@ var shellSafeRe = regexp.MustCompile(`^[A-Za-z0-9_./:@%+=-]+$`) // shellQuote renders s safe to embed in an emitted shell command. Clearly // inert strings pass through bare; anything else is single-quoted — the one // POSIX form in which nothing substitutes — with embedded single quotes -// spelled '\”. This is an encoding applied to every embedded value, not a +// spliced out and back in as: +// +// '\'' +// +// indented so gofmt leaves it as written. This is an +// encoding applied to every embedded value, not a // metacharacter list: breadcrumbs interpolate user- and API-controlled text, // and escaping cases one at a time is how quoting bugs recur. func shellQuote(s string) string { diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go index fbdca71a2..e8e328a84 100644 --- a/internal/commands/mcp_connect_test.go +++ b/internal/commands/mcp_connect_test.go @@ -54,7 +54,7 @@ func connectStateWithTask(t *testing.T) (string, connector.TaskGrant, *connector RequesterID: 26909558, State: admission.StateAdmitted, Trigger: admission.TriggerMentioned, Acknowledge: true, ConversationKey: "recording:501", Reply: &admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: 501}, - Routed: true, Route: "/work/secret-route", Class: "internal", + Served: true, Class: "internal", Snapshot: &admission.Snapshot{Type: "Todo", Title: "A to-do", Content: "please do it"}, }) require.NoError(t, err) @@ -112,7 +112,9 @@ func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(text), &body)) assert.Equal(t, int64(1), body.Instruction.EventID) assert.Equal(t, "please do it", body.Instruction.Content) - assert.NotContains(t, text, "secret-route") + for _, absent := range []string{"route", "work_dir", "path", "position"} { + assert.NotContains(t, text, `"`+absent+`"`, "the instruction is an allowlist; no directory and no feed position") + } assert.NotContains(t, text, grant.Token) // Read back through the connector's own handle: a repeat writes nothing, diff --git a/internal/commands/profile_layers_test.go b/internal/commands/profile_layers_test.go index 9dc6f8166..8d92e2d2b 100644 --- a/internal/commands/profile_layers_test.go +++ b/internal/commands/profile_layers_test.go @@ -30,7 +30,7 @@ func TestConnectSetupSeesTheGlobalBindingThroughALocalEntry(t *testing.T) { trustedLocalConfig(t, fmt.Sprintf(`{"profiles":{"agent":{"base_url":%q,"project_id":"42"}}}`, s.srv.URL)) app := newConnectSetupApp(t, s, "agent") - out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, app, "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.NoError(t, err, out) f, err := setup.Load(connectSetupPath(t, "agent")) require.NoError(t, err) @@ -46,7 +46,7 @@ func TestConnectSetupNamesTheFileThatHidesTheGlobalBinding(t *testing.T) { connectSetupApp(t, s, "agent") local := trustedLocalConfig(t, fmt.Sprintf(`{"profiles":{"agent":{"base_url":%q}}}`, elsewhereBaseURL)) - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) assert.Contains(t, err.Error(), "not bound to an account") hint := hintOf(t, err) @@ -189,7 +189,7 @@ func TestConnectSetupReportsAnUnusableGlobalConfigAsItself(t *testing.T) { func assertSetupReportsTheGlobalConfig(t *testing.T, s *connectSetupServer) { t.Helper() - out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), routeArg(t)) + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator", fmt.Sprint(setupOperatorPerson), serveArg()) require.Error(t, err, out) hint := hintOf(t, err) assert.NotContains(t, hint, "not the global config", "the global config was skipped, so it is no evidence") diff --git a/internal/config/config.go b/internal/config/config.go index 6d582c201..f62b5dc24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -896,8 +896,18 @@ func IsHTTPURL(rawURL string) bool { } // ShellQuote returns a POSIX single-quoted string safe for copy-paste into -// a shell. Single quotes inside the value are escaped as '\” (end quote, -// escaped literal quote, resume quote). +// a shell. A single quote cannot be escaped inside single quotes, so each +// one in the value is spliced out and back in as: +// +// '\'' +// +// that is: quote, backslash, quote, quote. It is written as an indented +// block on purpose — gofmt reformats doc-comment prose and rewrites that +// sequence into a curly closing quote, so a caller copying it out of prose +// would get a form no shell reads (Copilot on #765). +// +// internal/richtext.ShellQuote is the shared one new code should use; this +// copy predates it, as do the ones in internal/commands and internal/auth. func ShellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } diff --git a/internal/connector/admission/admission_test.go b/internal/connector/admission/admission_test.go index 2181c2ae4..c09a1051d 100644 --- a/internal/connector/admission/admission_test.go +++ b/internal/connector/admission/admission_test.go @@ -2,6 +2,7 @@ package admission import ( "context" + "errors" "strconv" "testing" "time" @@ -49,7 +50,7 @@ const ( ) // TestEveryCatalogueTypeHasAVerdict runs one operator-performed event of every -// cataloged type, in a routed project, through admission. The v1 matrix types +// cataloged type, in a served project, through admission. The v1 matrix types // are admitted under their trigger; everything else is discarded at the gate // for the price of its pointer. func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { @@ -69,7 +70,7 @@ func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { for _, eventType := range catalog { t.Run(eventType, func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
please look "+mentionOf(t, agentID)+"
") + s := summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
please look "+mentionOf(t, agentID)+"
") s.Parent = &basecamp.Parent{ID: parentID} s.CampfireID = campfireID s.Assignees = []basecamp.Person{{ID: agentID}} @@ -77,7 +78,7 @@ func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { f.assignments[eventID] = []int64{agentID} v := decide(t, newAdmitter(t, basePolicy(), f), Event{ - ID: eventID, EventType: eventType, BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID, + ID: eventID, EventType: eventType, BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID, }) want, inMatrix := admitted[eventType] @@ -90,7 +91,7 @@ func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { assert.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) assert.Equal(t, want, v.Trigger) assert.Equal(t, operatorID, v.RequesterID) - assert.Equal(t, "/work/connector", v.Route) + assert.True(t, v.Served) assert.Equal(t, "internal", v.Class) require.NotNil(t, v.Snapshot) assert.Contains(t, v.Snapshot.Content, "please look") @@ -104,12 +105,12 @@ func TestMentionIsMatchedByPersonIDNeverByName(t *testing.T) { // attachment names the agent's Person id. content := `
@Marie Chef (Agent) please
Marie Chef (Agent)Marie Chef (Agent)
` - s := summaryWith(recordingID, routedProj, "Message", operatorID, content) + s := summaryWith(recordingID, servedProj, "Message", operatorID, content) require.Equal(t, []int64{strangerID}, s.MentionedPersonIDs) f.summaries[recordingID] = s v := decide(t, newAdmitter(t, basePolicy(), f), Event{ - ID: eventID, EventType: "message.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID, + ID: eventID, EventType: "message.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID, }) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonNotAddressed, v.Reason) @@ -129,12 +130,12 @@ func TestPublishedDraft(t *testing.T) { } { t.Run(tc.status, func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Message", operatorID, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Message", operatorID, mentionOf(t, agentID)) s.Status = tc.status f.summaries[recordingID] = s v := decide(t, newAdmitter(t, basePolicy(), f), Event{ - ID: eventID, EventType: "message.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID, + ID: eventID, EventType: "message.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID, }) assert.Equal(t, tc.state, v.State) assert.Equal(t, tc.reason, v.Reason) @@ -151,11 +152,11 @@ func TestPublishedDraft(t *testing.T) { } func TestChatLine(t *testing.T) { - ev := Event{ID: eventID, EventType: "chat.line.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID} + ev := Event{ID: eventID, EventType: "chat.line.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} t.Run("mention in a line is answered in its Campfire", func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Chat::Lines::RichText", operatorID, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Chat::Lines::RichText", operatorID, mentionOf(t, agentID)) s.CampfireID = campfireID f.summaries[recordingID] = s @@ -167,7 +168,7 @@ func TestChatLine(t *testing.T) { t.Run("a line under no visible Campfire is blocked, not retried in place", func(t *testing.T) { f := newFakeReads() - f.summaryErrs = []error{&basecamp.UnresolvedRecordingError{BucketID: routedProj, RecordingID: recordingID}} + f.summaryErrs = []error{&basecamp.UnresolvedRecordingError{BucketID: servedProj, RecordingID: recordingID}} v := decide(t, newAdmitter(t, basePolicy(), f), ev) assert.Equal(t, StateBlocked, v.State) @@ -179,7 +180,7 @@ func TestChatLine(t *testing.T) { t.Run("a resolved line without its Campfire cannot be answered", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Chat::Lines::RichText", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Chat::Lines::RichText", operatorID, mentionOf(t, agentID)) v := decide(t, newAdmitter(t, basePolicy(), f), ev) assert.Equal(t, StateBlocked, v.State) @@ -189,10 +190,10 @@ func TestChatLine(t *testing.T) { func TestAssignments(t *testing.T) { ev := func(performer int64) Event { - return Event{ID: eventID, EventType: "card.assignment_changed", BucketID: routedProj, RecordingID: recordingID, CreatorID: performer} + return Event{ID: eventID, EventType: "card.assignment_changed", BucketID: servedProj, RecordingID: recordingID, CreatorID: performer} } card := func(f *fakeReads) { - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", strangerID, "
a card
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", strangerID, "
a card
") } t.Run("an assignment that adds someone else is discarded", func(t *testing.T) { @@ -256,7 +257,7 @@ func TestAssignments(t *testing.T) { t.Run(tc.name, func(t *testing.T) { f := newFakeReads() card(f) - f.members[routedProj] = map[int64]bool{memberID: true} + f.members[servedProj] = map[int64]bool{memberID: true} f.assignments[eventID] = []int64{agentID} p := basePolicy() tc.policy(&p) @@ -271,12 +272,12 @@ func TestAssignments(t *testing.T) { } func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { - ev := Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID} + ev := Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} t.Run("five failures", func(t *testing.T) { f := newFakeReads() f.summaryErrs = []error{errTransport, errTransport, errTransport, errTransport, errTransport, nil} - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) var waits int a, err := NewAdmitter(basePolicy(), f.reads(), WithSleep(func(context.Context, time.Duration) error { waits++; return nil })) @@ -291,7 +292,7 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { t.Run("four failures then an answer", func(t *testing.T) { f := newFakeReads() f.summaryErrs = []error{errTransport, errTransport, errTransport, errTransport} - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) v := decide(t, newAdmitter(t, basePolicy(), f), ev) assert.Equal(t, StateAdmitted, v.State) @@ -300,10 +301,10 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { t.Run("a failed subscription read blocks too", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
done
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
done
") f.subErr = errTransport - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.completed", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.completed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonReadFailed, v.Reason) assert.Len(t, f.subCalls, DefaultReadAttempts) @@ -311,10 +312,10 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { t.Run("a failed assignment read blocks too", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
card
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
card
") f.assignErr = errTransport - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.assignment_changed", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.assignment_changed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonReadFailed, v.Reason) assert.Equal(t, DefaultReadAttempts, f.assignCalls) @@ -365,7 +366,7 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { t.Run("a canceled context during a subscription read is not a verdict", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
done
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
done
") f.subErr = errTransport ctx, cancel := context.WithCancel(context.Background()) a, err := NewAdmitter(basePolicy(), f.reads(), WithSleep(func(context.Context, time.Duration) error { @@ -373,13 +374,13 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { return ctx.Err() })) require.NoError(t, err) - _, err = a.Decide(ctx, Event{ID: eventID, EventType: "card.completed", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + _, err = a.Decide(ctx, Event{ID: eventID, EventType: "card.completed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) require.ErrorIs(t, err, context.Canceled) }) t.Run("a canceled context during an assignment read is not a verdict", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
card
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
card
") f.assignErr = errTransport ctx, cancel := context.WithCancel(context.Background()) a, err := NewAdmitter(basePolicy(), f.reads(), WithSleep(func(context.Context, time.Duration) error { @@ -387,7 +388,7 @@ func TestAReadThatFailsFiveTimesBlocks(t *testing.T) { return ctx.Err() })) require.NoError(t, err) - _, err = a.Decide(ctx, Event{ID: eventID, EventType: "card.assignment_changed", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + _, err = a.Decide(ctx, Event{ID: eventID, EventType: "card.assignment_changed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) require.ErrorIs(t, err, context.Canceled) }) @@ -427,9 +428,9 @@ func TestCompletions(t *testing.T) { t.Run("the agent has no stake: discarded", func(t *testing.T) { f := newFakeReads() - todo(f, routedProj) + todo(f, servedProj) - v := decide(t, newAdmitter(t, basePolicy(), f), completion(routedProj, operatorID)) + v := decide(t, newAdmitter(t, basePolicy(), f), completion(servedProj, operatorID)) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonNotAddressed, v.Reason) assert.Equal(t, []int64{recordingID}, f.subCalls, "the subscription asked is the completed recording's own") @@ -437,9 +438,9 @@ func TestCompletions(t *testing.T) { t.Run("assigned to the agent: admitted with no subscription read", func(t *testing.T) { f := newFakeReads() - todo(f, routedProj).Assignees = []basecamp.Person{{ID: strangerID}, {ID: agentID}} + todo(f, servedProj).Assignees = []basecamp.Person{{ID: strangerID}, {ID: agentID}} - v := decide(t, newAdmitter(t, basePolicy(), f), completion(routedProj, operatorID)) + v := decide(t, newAdmitter(t, basePolicy(), f), completion(servedProj, operatorID)) assert.Equal(t, StateAdmitted, v.State) assert.Equal(t, TriggerCompleted, v.Trigger) assert.Empty(t, f.subCalls) @@ -447,10 +448,10 @@ func TestCompletions(t *testing.T) { t.Run("subscribed: admitted", func(t *testing.T) { f := newFakeReads() - todo(f, routedProj) + todo(f, servedProj) f.subscriptions[recordingID] = true - v := decide(t, newAdmitter(t, basePolicy(), f), completion(routedProj, operatorID)) + v := decide(t, newAdmitter(t, basePolicy(), f), completion(servedProj, operatorID)) assert.Equal(t, StateAdmitted, v.State) assert.Equal(t, TriggerCompleted, v.Trigger) }) @@ -464,16 +465,16 @@ func TestCompletions(t *testing.T) { assert.Equal(t, TriggerCompleted, v.Trigger) assert.False(t, v.Acknowledge, "a completion is not a request: no acknowledgement, no guard") assert.Equal(t, &ReplyDestination{Kind: ReplyComment, RecordingID: recordingID}, v.Reply) - assert.Equal(t, "/work/board", v.Route) + assert.True(t, v.Served) assert.Empty(t, f.subCalls) assert.Equal(t, 1, f.summaryCalls) }) t.Run("in a project with no route: discarded before any read", func(t *testing.T) { f := newFakeReads() - todo(f, unmapped).Assignees = []basecamp.Person{{ID: agentID}} + todo(f, unservedProj).Assignees = []basecamp.Person{{ID: agentID}} - v := decide(t, newAdmitter(t, basePolicy(), f), completion(unmapped, operatorID)) + v := decide(t, newAdmitter(t, basePolicy(), f), completion(unservedProj, operatorID)) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonNoRoute, v.Reason) assert.Zero(t, f.totalReads()) @@ -542,9 +543,9 @@ func TestTheAgentNeverAuthorizesItself(t *testing.T) { t.Run("content the agent wrote, surfaced by a trusted person, is not an instruction", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", agentID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", agentID, mentionOf(t, agentID)) - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "todo.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "todo.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonAgentAuthored, v.Reason) }) @@ -553,11 +554,11 @@ func TestTheAgentNeverAuthorizesItself(t *testing.T) { func TestContentAuthorMustBeTrusted(t *testing.T) { // A to-do moved in from another project is served as todo.created with the // mover as creator. The instruction in it is the original author's. - ev := Event{ID: eventID, EventType: "todo.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID} + ev := Event{ID: eventID, EventType: "todo.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} t.Run("an untrusted author", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", strangerID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", strangerID, mentionOf(t, agentID)) v := decide(t, newAdmitter(t, basePolicy(), f), ev) assert.Equal(t, StateDiscarded, v.State) @@ -566,7 +567,7 @@ func TestContentAuthorMustBeTrusted(t *testing.T) { t.Run("an author the read did not name is a failed read, not an untrusted one", func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Todo", strangerID, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Todo", strangerID, mentionOf(t, agentID)) s.Creator = nil f.summaries[recordingID] = s @@ -578,7 +579,7 @@ func TestContentAuthorMustBeTrusted(t *testing.T) { t.Run("operator-written content moved in by an allowlisted person", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", operatorID, mentionOf(t, agentID)) p := basePolicy() p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}} moved := ev @@ -590,7 +591,7 @@ func TestContentAuthorMustBeTrusted(t *testing.T) { t.Run("content written by another Agent principal", func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Todo", otherAgent, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Todo", otherAgent, mentionOf(t, agentID)) s.Creator.PersonableType = "Agent" f.summaries[recordingID] = s p := basePolicy() @@ -603,7 +604,7 @@ func TestContentAuthorMustBeTrusted(t *testing.T) { t.Run("an allowlisted author moved in by the operator", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", allowedID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", allowedID, mentionOf(t, agentID)) p := basePolicy() p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}} @@ -613,12 +614,12 @@ func TestContentAuthorMustBeTrusted(t *testing.T) { t.Run("a subscription comment by an untrusted author", func(t *testing.T) { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Comment", strangerID, "
go
") + s := summaryWith(recordingID, servedProj, "Comment", strangerID, "
go
") s.Parent = &basecamp.Parent{ID: parentID} f.summaries[recordingID] = s f.subscriptions[parentID] = true - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "comment.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonUntrustedAuthor, v.Reason) }) @@ -628,14 +629,14 @@ func TestProjectTrustMode(t *testing.T) { p := basePolicy() p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID} comment := func(performer int64) Event { - return Event{ID: eventID, EventType: "comment.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: performer} + return Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: performer} } setup := func(t *testing.T, author int64) *fakeReads { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Comment", author, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Comment", author, mentionOf(t, agentID)) s.Parent = &basecamp.Parent{ID: parentID} f.summaries[recordingID] = s - f.members[routedProj] = map[int64]bool{memberID: true} + f.members[servedProj] = map[int64]bool{memberID: true} return f } @@ -659,7 +660,7 @@ func TestProjectTrustMode(t *testing.T) { // for the performer, and only the author's type gives it away. f := setup(t, otherAgent) f.summaries[recordingID].Creator.PersonableType = "Agent" - f.members[routedProj][otherAgent] = true + f.members[servedProj][otherAgent] = true v := decide(t, newAdmitter(t, p, f), comment(otherAgent)) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonAgentAuthored, v.Reason) @@ -709,10 +710,10 @@ func TestProjectTrustMode(t *testing.T) { } func TestSubscribedComments(t *testing.T) { - comment := Event{ID: eventID, EventType: "comment.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID} + comment := Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} setup := func(content string) *fakeReads { f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Comment", operatorID, content) + s := summaryWith(recordingID, servedProj, "Comment", operatorID, content) s.Parent = &basecamp.Parent{ID: parentID} f.summaries[recordingID] = s return f @@ -749,12 +750,12 @@ func TestSubscribedComments(t *testing.T) { assert.Empty(t, v.Trigger) }) - t.Run("in an unmapped project only the mention is considered", func(t *testing.T) { + t.Run("in an unservedProj project only the mention is considered", func(t *testing.T) { f := setup("
chatter
") - f.summaries[recordingID].Bucket = &basecamp.Bucket{ID: unmapped} + f.summaries[recordingID].Bucket = &basecamp.Bucket{ID: unservedProj} f.subscriptions[parentID] = true ev := comment - ev.BucketID = unmapped + ev.BucketID = unservedProj v := decide(t, newAdmitter(t, basePolicy(), f), ev) assert.Equal(t, StateDiscarded, v.State) @@ -764,27 +765,27 @@ func TestSubscribedComments(t *testing.T) { } func TestNoRoute(t *testing.T) { - t.Run("a mention in an unmapped project is blocked and keeps no content", func(t *testing.T) { + t.Run("a mention in an unservedProj project is blocked and keeps no content", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, unmapped, "Kanban::Card", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, unservedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.created", BucketID: unmapped, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.created", BucketID: unservedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonNoRoute, v.Reason) - assert.False(t, v.Routed) + assert.False(t, v.Served) assert.Nil(t, v.Snapshot, "only an admitted record carries content; this one is read again when a route appears") assert.NotNil(t, v.Reply, "the holding reply needs a destination") assert.Equal(t, TriggerMentioned, v.Trigger) assert.NotEmpty(t, v.RecordingURL) }) - t.Run("an assignment in an unmapped project is blocked", func(t *testing.T) { + t.Run("an assignment in an unservedProj project is blocked", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, unmapped, "Todo", strangerID, "
todo
") + f.summaries[recordingID] = summaryWith(recordingID, unservedProj, "Todo", strangerID, "
todo
") f.summaries[recordingID].Assignees = []basecamp.Person{{ID: agentID}} f.assignments[eventID] = []int64{agentID} - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "todo.assignment_changed", BucketID: unmapped, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "todo.assignment_changed", BucketID: unservedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonNoRoute, v.Reason) }) @@ -795,7 +796,7 @@ func TestScope(t *testing.T) { p := basePolicy() p.Buckets = []int64{watchedProj} - v := decide(t, newAdmitter(t, p, f), Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, p, f), Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonOutOfScope, v.Reason) assert.Zero(t, f.totalReads()) @@ -803,7 +804,7 @@ func TestScope(t *testing.T) { func TestInvalidPointer(t *testing.T) { f := newFakeReads() - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.created", BucketID: routedProj, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.created", BucketID: servedProj, CreatorID: operatorID}) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonInvalidPointer, v.Reason) assert.Zero(t, f.totalReads()) @@ -820,12 +821,12 @@ func TestAllowlistMode(t *testing.T) { p := basePolicy() p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}} card := func(performer int64) Event { - return Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: performer} + return Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: performer} } t.Run("an allowlisted person is trusted", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", allowedID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", allowedID, mentionOf(t, agentID)) v := decide(t, newAdmitter(t, p, f), card(allowedID)) assert.Equal(t, StateAdmitted, v.State) assert.Zero(t, f.memberCalls) @@ -833,8 +834,8 @@ func TestAllowlistMode(t *testing.T) { t.Run("anyone else is not, without a read", func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", memberID, mentionOf(t, agentID)) - f.members[routedProj] = map[int64]bool{memberID: true} + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", memberID, mentionOf(t, agentID)) + f.members[servedProj] = map[int64]bool{memberID: true} v := decide(t, newAdmitter(t, p, f), card(memberID)) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonUntrustedPerformer, v.Reason) @@ -845,7 +846,7 @@ func TestAllowlistMode(t *testing.T) { func TestProjectModeContentAuthor(t *testing.T) { p := basePolicy() p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID} - movedIn := Event{ID: eventID, EventType: "todo.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID} + movedIn := Event{ID: eventID, EventType: "todo.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} for _, tc := range []struct { author int64 @@ -857,8 +858,8 @@ func TestProjectModeContentAuthor(t *testing.T) { } { t.Run(strconv.FormatInt(tc.author, 10), func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", tc.author, mentionOf(t, agentID)) - f.members[routedProj] = map[int64]bool{memberID: true} + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", tc.author, mentionOf(t, agentID)) + f.members[servedProj] = map[int64]bool{memberID: true} v := decide(t, newAdmitter(t, p, f), movedIn) assert.Equal(t, tc.state, v.State) @@ -872,14 +873,14 @@ func TestSubscribedNeverStandsInForARefusedMention(t *testing.T) { // With a matrix where subscription is the only rule, a comment that // mentions the agent is still not a subscription trigger. f := newFakeReads() - s := summaryWith(recordingID, routedProj, "Comment", operatorID, mentionOf(t, agentID)) + s := summaryWith(recordingID, servedProj, "Comment", operatorID, mentionOf(t, agentID)) s.Parent = &basecamp.Parent{ID: parentID} f.summaries[recordingID] = s f.subscriptions[parentID] = true a, err := NewAdmitter(basePolicy(), f.reads(), WithMatrix(Matrix{"comment.created": {ruleSubscribed}})) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "comment.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonNotAddressed, v.Reason) assert.Empty(t, f.subCalls) @@ -889,13 +890,13 @@ func TestASubscriptionRuleWithoutAParentBlocks(t *testing.T) { // A later matrix row may put the subscribed rule on a type the // completeness check does not key on; the rule guards itself. f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Message", operatorID, "
news
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Message", operatorID, "
news
") a, err := NewAdmitter(basePolicy(), f.reads(), WithMatrix(Matrix{"message.created": {ruleSubscribed}})) require.NoError(t, err) var v Verdict require.NotPanics(t, func() { - v = decide(t, a, Event{ID: eventID, EventType: "message.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v = decide(t, a, Event{ID: eventID, EventType: "message.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) }) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonReadFailed, v.Reason) @@ -908,9 +909,9 @@ func TestACommentWithoutItsRecordingCannotBeAnswered(t *testing.T) { } { t.Run(name, func(t *testing.T) { f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Comment", operatorID, content) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Comment", operatorID, content) - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "comment.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonReadFailed, v.Reason) assert.Nil(t, v.Snapshot, "a blocked record keeps no content") @@ -932,14 +933,14 @@ func TestAnEmptySummaryIsAFailedRead(t *testing.T) { a, err := NewAdmitter(basePolicy(), reads, WithSleep(func(context.Context, time.Duration) error { return nil })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonReadFailed, v.Reason) } func TestAVerdictCarriesTheRevisionItWasDecidedFrom(t *testing.T) { f := newFakeReads() - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.moved", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID, Revision: 4}) + v := decide(t, newAdmitter(t, basePolicy(), f), Event{ID: eventID, EventType: "card.moved", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID, Revision: 4}) assert.EqualValues(t, 4, v.Revision) } @@ -947,7 +948,7 @@ func TestAThrottledReadWaitsAsLongAsTheServerAsks(t *testing.T) { f := newFakeReads() throttled := &basecamp.Error{Code: basecamp.CodeRateLimit, Message: "slow down", HTTPStatus: 429, Retryable: true, RetryAfter: 60} f.summaryErrs = []error{throttled, throttled} - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) var waits []time.Duration a, err := NewAdmitter(basePolicy(), f.reads(), WithSleep(func(_ context.Context, d time.Duration) error { waits = append(waits, d) @@ -955,7 +956,7 @@ func TestAThrottledReadWaitsAsLongAsTheServerAsks(t *testing.T) { })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateAdmitted, v.State) require.Len(t, waits, 2) for _, w := range waits { @@ -971,7 +972,7 @@ func TestAThrottleLongerThanTheBudgetIsHeldUntilTheDeadline(t *testing.T) { WithSleep(func(context.Context, time.Duration) error { waited = true; return nil })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonThrottled, v.Reason) assert.Equal(t, testNow.Add(time.Hour), v.RetryAt) @@ -991,14 +992,14 @@ func TestTheThrottleBudgetIsSharedAcrossADecisionsReads(t *testing.T) { } f := newFakeReads() f.summaryErrs = []error{throttle()} - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
done
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
done
") f.subErr = throttle() var waits []time.Duration a, err := NewAdmitter(basePolicy(), f.reads(), WithClock(func() time.Time { return testNow }), WithSleep(func(_ context.Context, d time.Duration) error { waits = append(waits, d); return nil })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.completed", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.completed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonThrottled, v.Reason) assert.Equal(t, []time.Duration{90 * time.Second}, waits) @@ -1014,7 +1015,7 @@ func TestAThrottleOnTheLastAttemptIsHeldNotFailed(t *testing.T) { WithSleep(func(context.Context, time.Duration) error { return nil })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) assert.Equal(t, ReasonThrottled, v.Reason) assert.Equal(t, testNow.Add(time.Second), v.RetryAt) assert.Equal(t, DefaultReadAttempts, f.summaryCalls) @@ -1028,7 +1029,7 @@ func TestAThrottledMembershipReadIsHeld(t *testing.T) { a, err := NewAdmitter(p, f.reads(), WithClock(func() time.Time { return testNow })) require.NoError(t, err) - v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: memberID}) + v := decide(t, a, Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: memberID}) assert.Equal(t, StateBlocked, v.State) assert.Equal(t, ReasonThrottled, v.Reason) assert.Equal(t, testNow.Add(10*time.Minute), v.RetryAt) @@ -1039,18 +1040,18 @@ func TestMembershipIsAskedAsOfWhenTheEventWasSeen(t *testing.T) { p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID} seen := testNow.Add(-time.Hour) f := newFakeReads() - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", memberID, mentionOf(t, agentID)) - f.members[routedProj] = map[int64]bool{memberID: true} + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", memberID, mentionOf(t, agentID)) + f.members[servedProj] = map[int64]bool{memberID: true} - ev := Event{ID: eventID, EventType: "todo.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: memberID, SeenAt: seen} + ev := Event{ID: eventID, EventType: "todo.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: memberID, SeenAt: seen} decide(t, newAdmitter(t, p, f), ev) require.Len(t, f.memberAsOf, 1) assert.Equal(t, seen, f.memberAsOf[0], "the performer's membership, as of the event") // An author distinct from the performer is asked as of the event too. g := newFakeReads() - g.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", memberID, mentionOf(t, agentID)) - g.members[routedProj] = map[int64]bool{memberID: true} + g.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", memberID, mentionOf(t, agentID)) + g.members[servedProj] = map[int64]bool{memberID: true} moved := ev moved.CreatorID = operatorID decide(t, newAdmitter(t, p, g), moved) @@ -1060,8 +1061,8 @@ func TestMembershipIsAskedAsOfWhenTheEventWasSeen(t *testing.T) { // Unknown seen time: the time admission started, never the zero time, // which every cached listing would satisfy. h := newFakeReads() - h.summaries[recordingID] = summaryWith(recordingID, routedProj, "Todo", memberID, mentionOf(t, agentID)) - h.members[routedProj] = map[int64]bool{memberID: true} + h.summaries[recordingID] = summaryWith(recordingID, servedProj, "Todo", memberID, mentionOf(t, agentID)) + h.members[servedProj] = map[int64]bool{memberID: true} before := time.Now() unknown := ev unknown.SeenAt = time.Time{} @@ -1069,3 +1070,187 @@ func TestMembershipIsAskedAsOfWhenTheEventWasSeen(t *testing.T) { require.Len(t, h.memberAsOf, 1) assert.False(t, h.memberAsOf[0].Before(before)) } + +// Copilot on #765: admission reads the served projects at each decision, not +// once when the connector started. +// +// The dispatcher's reader already re-read the file, so serving a project while the +// connector runs used to change only half the answer: admission went on +// deciding against the startup policy. Unserving one left events admitted and +// then silently unstarted — no work and no holding reply, so the person who +// mentioned the agent got nothing. Serving one left events blocked no_route, +// and the holding reply's own remedy ("a person can run it with redispatch") +// could not work, because the redispatch re-ran admission against the same +// stale policy. +func TestAdmissionReadsTheServedProjectsAsTheyAreNow(t *testing.T) { + live := map[int64]Project{servedProj: {Class: "internal"}} + f := newFakeReads() + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + a := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { return live, nil })) + + ev := Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} + v := decide(t, a, ev) + require.Equal(t, StateAdmitted, v.State) + assert.True(t, v.Served) + assert.Equal(t, "internal", v.Class) + + // The operator stops serving it. The next event is answered, not + // swallowed: blocked no_route is what the holding reply is written for. + delete(live, servedProj) + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + v = decide(t, a, Event{ID: eventID + 1, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonNoRoute, v.Reason) + assert.False(t, v.Served) + + // And serving it again takes effect without a restart, which is what + // makes the holding reply's redispatch a real remedy. + live[servedProj] = Project{Class: "client-work"} + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + v = decide(t, a, Event{ID: eventID + 2, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) + assert.Equal(t, StateAdmitted, v.State) + assert.Equal(t, "client-work", v.Class, "and the entry read is the live one, not the startup copy") +} + +// A trigger the gate only admits in a served project is discarded there once +// the project stops being served, and is not answered: only mentioned and +// assigned get a holding reply. +func TestTheGateReadsTheLiveServedSetToo(t *testing.T) { + live := map[int64]Project{servedProj: {}} + f := newFakeReads() + summary := summaryWith(recordingID, servedProj, "Todo", operatorID, "
ship it
") + summary.Assignees = []basecamp.Person{{ID: agentID}} + f.summaries[recordingID] = summary + a := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { return live, nil })) + + delete(live, servedProj) + v := decide(t, a, Event{ID: eventID, EventType: "card.completed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonNoRoute, v.Reason) +} + +// Copilot on #765: an unreadable connect.json is not "the operator serves no +// projects", and must not be answered as if it were. +// +// The served set feeds admission now, so a parse, permission or read failure +// that came back as an empty map would make every trusted mention +// blocked(no_route) +// and post the public holding reply — telling the person on the card that +// their project is not served, when the project is there and the file is +// what is broken. no_route also has no timed retry, so repairing the file +// would not reconsider those records. +func TestAnUnreadableConfigIsHeldAsOneRatherThanAnsweredAsUnserved(t *testing.T) { + broken := errors.New("connect.json cannot be read") + fail := true + f := newFakeReads() + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + a := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { + if fail { + return nil, broken + } + return map[int64]Project{servedProj: {Class: "internal"}}, nil + })) + + ev := Event{ID: eventID, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} + v := decide(t, a, ev) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonConfigUnreadable, v.Reason, "not no_route: nothing read the file, so nothing can say the project is unserved") + assert.False(t, v.Served) + + // It waits for a person, as every blocked record does. An automatic + // sweep was built for this and taken back out: offering due blocked + // rows is a scheduler with its own claiming, and it re-decided five + // other blocked reasons besides this one. Carded, so that nothing here + // promises a timer that does not run. + blockedAt := time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC) + _, retried := NextBlockedRetry(ReasonConfigUnreadable, blockedAt, blockedAt, time.Time{}) + assert.False(t, retried, "no schedule claims this record, and nothing would act on one if it did") + + // And once the file is readable the record decides normally. + fail = false + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, mentionOf(t, agentID)) + v = decide(t, a, Event{ID: eventID + 1, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) + assert.Equal(t, StateAdmitted, v.State) +} + +// A trigger the gate only admits in a served project is held too, not +// discarded: throwing the work away over a file nobody could read is the one +// outcome that cannot be undone by fixing the file. +func TestAnUnreadableConfigHoldsWhatTheGateWouldHaveDiscarded(t *testing.T) { + f := newFakeReads() + summary := summaryWith(recordingID, servedProj, "Todo", operatorID, "
ship it
") + summary.Assignees = []basecamp.Person{{ID: agentID}} + f.summaries[recordingID] = summary + a := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { + return nil, errors.New("connect.json cannot be read") + })) + + v := decide(t, a, Event{ID: eventID, EventType: "card.completed", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonConfigUnreadable, v.Reason) +} + +// Copilot on #765: one verdict, one configuration. match used to read the +// served map again after the admission reads, so a setting changed in that +// window could admit the event on the new entry and stamp it with the old +// entry's class. +func TestAVerdictIsBuiltFromOneSnapshotOfTheServedProjects(t *testing.T) { + reads := 0 + f := newFakeReads() + summary := summaryWith(recordingID, watchedProj, "Todo", operatorID, "
ship it
") + f.summaries[recordingID] = summary + a := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { + reads++ + // A different answer every time it is asked: a decision that reads + // twice cannot help but mix them. + if reads == 1 { + return map[int64]Project{watchedProj: {Class: "first", WatchCompletions: true}}, nil + } + return map[int64]Project{watchedProj: {Class: "second", WatchCompletions: true}}, nil + })) + + v := decide(t, a, Event{ID: eventID, EventType: "card.completed", BucketID: watchedProj, RecordingID: recordingID, CreatorID: operatorID}) + require.Equal(t, StateAdmitted, v.State) + assert.Equal(t, 1, reads, "the served projects are read once per decision, then passed down") + assert.Equal(t, "first", v.Class, "and the class is the snapshot the rest of the verdict was decided from") +} + +// Copilot on #765: an unreadable connect.json must not permanently discard a +// subscribed comment, which is the one outcome repairing the file cannot +// reverse. +// +// comment.created admits under two rules: mentioned, which needs no served +// project, and subscribed, which does. With the list unreadable the gate +// keeps mentioned and drops subscribed, so the gate does not discard — and +// match then finds no mention and returns not_addressed, terminally, before +// anything notices the configuration could not be read. The hold has to come +// before that return, not after it. +func TestAnUnreadableConfigDoesNotDiscardASubscribedComment(t *testing.T) { + f := newFakeReads() + // A comment on a recording the agent is subscribed to, mentioning nobody. + summary := summaryWith(recordingID, servedProj, "Comment", operatorID, "
a thought
") + summary.Parent = &basecamp.Parent{ID: recordingID + 1} + f.summaries[recordingID] = summary + f.subscriptions[recordingID+1] = true + + ev := Event{ID: eventID, EventType: "comment.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} + + // Readable and served, the same event is admitted as subscribed: this is + // work that would have run. + served := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { + return map[int64]Project{servedProj: {}}, nil + })) + v := decide(t, served, ev) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, TriggerSubscribed, v.Trigger) + + // Unreadable, it is held — not discarded not_addressed, which no repair + // of the file could undo. + unknown := newAdmitter(t, basePolicy(), f, WithServed(func() (map[int64]Project, error) { + return nil, errors.New("connect.json cannot be read") + })) + v = decide(t, unknown, ev) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonConfigUnreadable, v.Reason) + assert.NotEqual(t, ReasonNotAddressed, v.Reason, "a discard here is unrecoverable") +} diff --git a/internal/connector/admission/commit.go b/internal/connector/admission/commit.go index b1079a8c5..563fe3815 100644 --- a/internal/connector/admission/commit.go +++ b/internal/connector/admission/commit.go @@ -146,10 +146,11 @@ const ( ) // NextBlockedRetry returns when a blocked record should next be re-run, and -// false when it waits for something other than time: a route (no_route), or -// a person's redispatch once the window has passed. notBefore is a throttled -// record's Verdict.RetryAt; no retry is scheduled before it, and a deadline -// past the window hands the record to redispatch rather than asking early. +// false when it waits for something other than time: the operator serving the +// project (no_route), or a person's redispatch once the window has passed. +// notBefore is a throttled record's Verdict.RetryAt; no retry is scheduled +// before it, and a deadline past the window hands the record to redispatch +// rather than asking early. func NextBlockedRetry(reason Reason, blockedAt, lastAttempt, notBefore time.Time) (time.Time, bool) { switch reason { case ReasonReadFailed, ReasonReadUnresolved, ReasonDeltaUnverified, ReasonTrustUnverified, ReasonThrottled: diff --git a/internal/connector/admission/commit_test.go b/internal/connector/admission/commit_test.go index 4b0f5d8a1..9473b7e7b 100644 --- a/internal/connector/admission/commit_test.go +++ b/internal/connector/admission/commit_test.go @@ -303,14 +303,14 @@ func (s *syncBuffer) String() string { func TestRunDecidesCommitsAndReportsWithoutContent(t *testing.T) { f := newFakeReads() const secret = "the body of the instruction" - f.summaries[recordingID] = summaryWith(recordingID, routedProj, "Kanban::Card", operatorID, "
"+secret+" "+mentionOf(t, agentID)+"
") + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", operatorID, "
"+secret+" "+mentionOf(t, agentID)+"
") ledger := newFakeLedger() out := &syncBuffer{} source := &sliceSource{ids: []int64{3, 1, 2, 1}} records := mapRecords{ - 1: {ID: 1, EventType: "card.created", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}, - 2: {ID: 2, EventType: "card.moved", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}, + 1: {ID: 1, EventType: "card.created", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}, + 2: {ID: 2, EventType: "card.moved", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}, // 3 is no longer seen: skipped. } ctx, cancel := context.WithCancel(context.Background()) @@ -346,7 +346,7 @@ func TestRunDecidesCommitsAndReportsWithoutContent(t *testing.T) { } assert.Equal(t, map[string]any{ "type": "event", "event_id": float64(1), "event_type": "card.created", "trigger": "mentioned", - "class": "internal", "route": "/work/connector", "bucket_id": float64(routedProj), "recording_id": float64(recordingID), + "class": "internal", "bucket_id": float64(servedProj), "recording_id": float64(recordingID), "recording_url": "https://app.basecamp.com/2914079/buckets/1/recordings/1", "requester_id": float64(operatorID), "state": "admitted", }, lines[1]) assert.Equal(t, "discarded", lines[2]["state"]) @@ -422,7 +422,6 @@ func TestLinesCannotCarryTerminalControls(t *testing.T) { EventID: 1, EventType: "card.created" + esc + "]0;owned" + bel, RecordingURL: "https://app.basecamp.com/x" + csi + "31m" + esc + "[2J", - Route: "/work" + esc + "[1m", State: StateDiscarded, Reason: ReasonNotInMatrix, })) @@ -441,7 +440,7 @@ func TestRunStopsOnACommitFailure(t *testing.T) { ledger.failure = errors.New("disk I/O error") err := Run(ctx, RunOptions{ Source: &sliceSource{ids: []int64{1}}, - Records: mapRecords{1: {ID: 1, EventType: "card.moved", BucketID: routedProj, RecordingID: recordingID, CreatorID: operatorID}}, + Records: mapRecords{1: {ID: 1, EventType: "card.moved", BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID}}, Admitter: newAdmitter(t, basePolicy(), newFakeReads()), Committer: NewCommitter(ledger), }) @@ -462,12 +461,12 @@ func TestRunStopsOnALoadFailure(t *testing.T) { require.ErrorContains(t, err, "database is locked") } -func TestLinesKeepLocalPathsAsWritten(t *testing.T) { +func TestLinesKeepWhitespaceAsWritten(t *testing.T) { var b strings.Builder - require.NoError(t, newLineWriter(nil, &b).write(Verdict{EventID: 1, State: StateAdmitted, Route: " /work/My Projects\tA", Class: "in ternal"})) + require.NoError(t, newLineWriter(nil, &b).write(Verdict{EventID: 1, State: StateAdmitted, EventType: " card. created\tx", Class: "in ternal"})) var m map[string]any require.NoError(t, json.Unmarshal([]byte(b.String()), &m)) - assert.Equal(t, " /work/My Projects\tA", m["route"], "whitespace in a path is not a terminal control") + assert.Equal(t, " card. created\tx", m["event_type"], "whitespace is not a terminal control") assert.Equal(t, "in ternal", m["class"]) } diff --git a/internal/connector/admission/doc.go b/internal/connector/admission/doc.go index 0e6626566..59ab0e35e 100644 --- a/internal/connector/admission/doc.go +++ b/internal/connector/admission/doc.go @@ -8,7 +8,7 @@ // 1. The gate (Gate) is pure code over the pointer and the local policy: the // event type is in the trigger matrix, the performer is trusted for at // least one of the type's triggers, the bucket is in scope, and a trigger -// that would be discarded without a route has one. Most account traffic +// that only a served project admits is in one. Most account traffic // ends here, for the price of its pointer. // 2. In project trust mode a performer other than the operator is confirmed // as a non-client member of the project. That is a read, cached per @@ -20,10 +20,11 @@ // agent's Person id is among the mention attachments — never a name), // subscribed (a subscription read on the commented recording), assigned // (the recording's events, at most five pages, for details.added_person_ids) -// or completed (trusted completer and a stake: the watch_completions route -// flag, an assignment, or a subscription). -// 5. The route, class, reply destination, conversation key and content -// snapshot come from the policy and the summary, never from the pointer. +// or completed (trusted completer and a stake: the served project's +// watch_completions flag, an assignment, or a subscription). +// 5. Whether the project is served, its class, the reply destination, the +// conversation key and the content snapshot come from the policy and the +// summary, never from the pointer. // // # Trust // diff --git a/internal/connector/admission/fakes_test.go b/internal/connector/admission/fakes_test.go index e302ba8b4..b34505e6d 100644 --- a/internal/connector/admission/fakes_test.go +++ b/internal/connector/admission/fakes_test.go @@ -15,25 +15,25 @@ import ( // People in the fixtures. The ids are arbitrary; what matters is who is who. const ( - agentID int64 = 52007412 - operatorID int64 = 26909558 - allowedID int64 = 1001 - memberID int64 = 1002 - clientID int64 = 1003 - strangerID int64 = 1004 - otherAgent int64 = 1005 - routedProj int64 = 48699913 - unmapped int64 = 777 - watchedProj int64 = 555 + agentID int64 = 52007412 + operatorID int64 = 26909558 + allowedID int64 = 1001 + memberID int64 = 1002 + clientID int64 = 1003 + strangerID int64 = 1004 + otherAgent int64 = 1005 + servedProj int64 = 48699913 + unservedProj int64 = 777 + watchedProj int64 = 555 ) func basePolicy() Policy { return Policy{ AgentID: agentID, Trust: Trust{Mode: TrustOperator, OperatorID: operatorID}, - Projects: map[int64]Route{ - routedProj: {Path: "/work/connector", Class: "internal"}, - watchedProj: {Path: "/work/board", Class: "internal", WatchCompletions: true}, + Projects: map[int64]Project{ + servedProj: {Class: "internal"}, + watchedProj: {Class: "internal", WatchCompletions: true}, }, } } @@ -130,9 +130,10 @@ func (f *fakeReads) totalReads() int { var errTransport = errors.New("connection reset") -func newAdmitter(t *testing.T, p Policy, f *fakeReads) *Admitter { +func newAdmitter(t *testing.T, p Policy, f *fakeReads, opts ...Option) *Admitter { t.Helper() - a, err := NewAdmitter(p, f.reads(), WithSleep(func(context.Context, time.Duration) error { return nil })) + opts = append([]Option{WithSleep(func(context.Context, time.Duration) error { return nil })}, opts...) + a, err := NewAdmitter(p, f.reads(), opts...) require.NoError(t, err) return a } diff --git a/internal/connector/admission/gate.go b/internal/connector/admission/gate.go index cc1314a6b..3eeb2ee52 100644 --- a/internal/connector/admission/gate.go +++ b/internal/connector/admission/gate.go @@ -23,7 +23,7 @@ func (g GateResult) Discarded() bool { return len(g.Rules) == 0 } // // The checks run in a fixed order and the first that ends the event names the // reason: the pointer's own ids, the matrix, the agent's own hand, scope, then -// per rule the trust set and the route. +// per rule the trust set and whether the project is served. func Gate(ev Event, p Policy, m Matrix) GateResult { if ev.ID <= 0 || ev.BucketID <= 0 || ev.RecordingID <= 0 || ev.CreatorID <= 0 { return GateResult{Reason: ReasonInvalidPointer} @@ -52,7 +52,7 @@ func Gate(ev Event, p Policy, m Matrix) GateResult { performer := ev.Performer() isOperator := performer == p.Trust.OperatorID - _, routed := p.route(ev.BucketID) + _, served := p.served(ev.BucketID) var ( open []Rule @@ -78,7 +78,7 @@ func Gate(ev Event, p Policy, m Matrix) GateResult { drop(ReasonUntrustedPerformer) continue } - if rule.RequiresRoute && !routed { + if rule.RequiresServed && !served { drop(ReasonNoRoute) continue } diff --git a/internal/connector/admission/keys.go b/internal/connector/admission/keys.go new file mode 100644 index 000000000..1c0ec5a36 --- /dev/null +++ b/internal/connector/admission/keys.go @@ -0,0 +1,99 @@ +package admission + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +// CheckCanonicalKeys walks a connect.json document and refuses any object +// that names a key twice or spells one noncanonically. Every reader of the +// file runs it, and it is exported so there is one implementation rather +// than one per reader: the whole point is that no two readers of the trust +// anchor can disagree about what a document says. +// +// The reason is encoding/json's field matching. It matches a struct tag +// case-insensitively and parses an integer map key with strconv, so to the +// decoder "Trust" is "trust", "PATH" is "path", and "01" is 1. A validation +// keyed on the canonical spelling therefore looks for a key the decoder has +// already matched under another one, and a value lands in a field nothing +// checked. Refusing the noncanonical spelling up front is what makes an +// exact comparison anywhere downstream a complete one. +// +// What that was worth here. Copilot on #765 reported one of these; the other +// five came out of looking at the seam the two readers share rather than at +// the field it named: +// +// {"Trust":{"mode":"allowlist","allowlist_ids":[…]}} trust mode escalated +// {"Projects":{"2":{}}} a second served project merged in +// {"projects":{"01":{}}} a served project setup refuses +// {"projects":{"1":{"Path":null}}} the legacy-path check skipped +// {"projects":{"1":{"PATH":"../x"}}} likewise, with a relative path +// +// The reported one is the fourth. Each of the others is the same mechanism +// wearing a different key, and the first is the trust anchor itself, which +// is why this is a document walk and not a check on "path". +func CheckCanonicalKeys(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var walk func() error + walk = func() error { + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]bool{} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key, _ := keyTok.(string) + if !canonicalKey(key) { + return fmt.Errorf("key %q is not spelled canonically: names are lowercase, project ids plain decimal", key) + } + if seen[key] { + return fmt.Errorf("key %q appears twice in one object", key) + } + seen[key] = true + if err := walk(); err != nil { + return err + } + } + case '[': + for dec.More() { + if err := walk(); err != nil { + return err + } + } + } + _, err = dec.Token() // the closing delimiter + return err + } + return walk() +} + +// canonicalKey reports whether a key is in its one canonical spelling: +// lowercase letters, digits and underscores for names, plain decimal for +// project ids. +func canonicalKey(key string) bool { + if n, err := strconv.ParseInt(key, 10, 64); err == nil { + return strconv.FormatInt(n, 10) == key + } + if key == "" { + return false + } + for _, r := range key { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + return true +} diff --git a/internal/connector/admission/matrix.go b/internal/connector/admission/matrix.go index 2697dad70..763fe21b6 100644 --- a/internal/connector/admission/matrix.go +++ b/internal/connector/admission/matrix.go @@ -19,11 +19,12 @@ type Rule struct { // whatever the trust mode. Assignments run the agent against a recording // on the assigner's say-so, so no broadened mode extends to them. OperatorOnly bool - // RequiresRoute discards the rule at the gate when the project has no - // route. Only mentioned and assigned are answered in an unmapped project - // (blocked(no_route) and a holding reply); every other trigger is - // discarded there, so it is cheaper to discard it before any read. - RequiresRoute bool + // RequiresServed discards the rule at the gate when connect.json does not + // serve the project. Only mentioned and assigned are answered in an + // unserved project (blocked(no_route) and a holding reply); every other + // trigger is discarded there, so it is cheaper to discard it before any + // read. + RequiresServed bool // Acknowledge says a person asked for something, so the worker's // acknowledgement and the thirty-second guard apply. A completion or a // subscription comment is not a request. @@ -37,9 +38,9 @@ type Matrix map[string][]Rule var ( ruleMentioned = Rule{Trigger: TriggerMentioned, Acknowledge: true} - ruleSubscribed = Rule{Trigger: TriggerSubscribed, RequiresRoute: true} + ruleSubscribed = Rule{Trigger: TriggerSubscribed, RequiresServed: true} ruleAssigned = Rule{Trigger: TriggerAssigned, OperatorOnly: true, Acknowledge: true} - ruleCompleted = Rule{Trigger: TriggerCompleted, RequiresRoute: true} + ruleCompleted = Rule{Trigger: TriggerCompleted, RequiresServed: true} ) // V1Matrix returns the version-1 trigger matrix. Every other cataloged type @@ -135,7 +136,26 @@ const ( // retried on a timer; only a redispatch re-runs it. ReasonUnroutable Reason = "unroutable" // ReasonNoRoute: shared by both states — a mentioned or assigned record in - // a project with no route is blocked (and answered with a holding reply); - // any other trigger there is discarded. + // a project connect.json does not serve is blocked (and answered with a + // holding reply); any other trigger there is discarded. + // + // The stored value stays "no_route", the word a served project was called + // by when it also named a directory. It is not renamed with the concept: + // it is written onto the record, and both the holding reply and the + // retraction that answers it read the record's reason back (askStillOpen, + // holdingReplyReason). A ledger in use carries rows and pending outbox + // intents written with this value, and a row is read as it was written. ReasonNoRoute Reason = "no_route" + // ReasonConfigUnreadable: the record's answer turns on which projects + // connect.json serves, and connect.json could not be read. Not + // no_route: that says the operator has not served this project, which + // would be a false thing to say — and to post a holding reply about — + // when the truth is that nothing could read the file. + // + // Like every other blocked reason, it waits for a person: repairing the + // file does not by itself decide these records, and `basecamp connect + // redispatch ` is what runs them. Nothing in the connector re-offers + // a blocked record on a timer — NextBlockedRetry describes a schedule + // no production code asks for — so this comment does not promise one. + ReasonConfigUnreadable Reason = "config_unreadable" ) diff --git a/internal/connector/admission/policy.go b/internal/connector/admission/policy.go index 163a440ed..270f756c6 100644 --- a/internal/connector/admission/policy.go +++ b/internal/connector/admission/policy.go @@ -1,10 +1,14 @@ package admission import ( + "bytes" "encoding/json" "errors" "fmt" + "path" "slices" + "strings" + "unicode/utf8" ) // TrustMode names who, besides the operator, may drive the agent. @@ -29,18 +33,177 @@ type Trust struct { AllowlistIDs []int64 `json:"allowlist_ids,omitempty"` } -// Route is one project's entry in connect.json: where its work runs and how -// admission treats it. Routes are local: nothing read from Basecamp can add or -// change one. -type Route struct { - // Path is the approved working directory the project maps to. - Path string `json:"path"` +// Project is one served project's entry in connect.json: a project the agent +// may be driven from, and how admission treats it. The entries are local: +// nothing read from Basecamp can add or change one, which is what makes the +// list an answer to "which projects may drive this agent" that only the +// operator writes. It matters most under TrustProject, where any non-client +// member of a served project can drive the agent. +// +// No directory is associated with a project. The connector runs where it was +// started; a task that needs a clone or a directory of its own is the agent's +// business to make. +type Project struct { // Class is the project's classification, carried on the record. Class string `json:"class,omitempty"` // WatchCompletions makes the agent a driver of the project: every trusted // completion in it is admitted as trigger completed, without the agent // being assigned or subscribed. WatchCompletions bool `json:"watch_completions,omitempty"` + + // LegacyPath is the directory a connector that routed projects to + // directories ran this project's work in. Nothing reads it: a task runs + // where the connector was started. It is still a field because + // setup.Parse refuses an unknown key, and every connect.json written + // before the paths went has "path" on every project — deleting the field + // outright would stop every connector already set up. setup.Parse zeroes + // it, and omitempty keeps it out of everything written from here, so the + // next `connect setup` writes the key away for good. + LegacyPath string `json:"path,omitempty"` +} + +// UnmarshalJSON refuses a null entry, and decodes an object strictly. +// +// Both matter, and the first is the one that bites. A JSON null decodes into +// a struct as the zero value without an error, so `"projects":{"123":null}` +// would read as project 123 served with no settings. Nothing noticed that +// before this file stopped carrying a path, because the path was required +// and a null has none — the check was doing two jobs and looked like it was +// doing one. connect.json is the trust anchor: a half-edited file, a bad +// merge or a truncated write has to withhold authorization, not grant it +// (Copilot on #765). +// +// The strictness is the second job the outer decoder used to do here. +// setup.Parse refuses an unknown key so that a misspelled watch_completion +// is a refusal rather than a project the operator believes is driven and is +// not — and a type's own UnmarshalJSON does not inherit that setting, so it +// is applied again here. It binds admission's reader too, which is the +// narrower of the two behaviors and the right one: the keys a project entry +// may carry are all known here, unlike the file's own, which carry other +// steps' settings. +func (p *Project) UnmarshalJSON(data []byte) error { + if string(bytes.TrimSpace(data)) == "null" { + return errors.New("a served project's entry is null; an entry is an object, {} for one with no settings") + } + // The entry's own keys, before anything reads one. DisallowUnknownFields + // below refuses a name this type does not have; it does not refuse a + // name it does have spelled differently or given twice, because the + // decoder matches case-insensitively and takes the last of a repeated + // key. So {"WATCH_COMPLETIONS":true} sets WatchCompletions, and + // {"class":"a","CLASS":"b"} silently keeps "b" — an entry that does not + // say what it reads as (Copilot on #765). + // + // Here rather than only in the two readers because Project is exported: + // whoever decodes an entry gets this, including setup's own + // refuseMalformedProjects, which unmarshals a raw entry into this type + // precisely to name the project behind a refusal. A component that can + // only be trusted when its caller did something first is one a later + // edit breaks without touching it. + if err := CheckCanonicalKeys(data); err != nil { + return err + } + // A local type to shed this method, or decoding recurses. + type project Project + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var raw project + if err := dec.Decode(&raw); err != nil { + return err + } + if err := checkLegacyPath(data); err != nil { + return err + } + *p = Project(raw) + return nil +} + +// checkLegacyPath holds a path key that is present to the shape the writer +// that produced it could only have written: a clean absolute path. +// +// LegacyPath is decoded and thrown away, which is not the same as not caring +// what it is. A connector that routed projects wrote nothing but clean +// absolute paths there, and setup refused a file carrying anything else — so +// a null, an empty string, a relative or an unclean one means the file is not +// what it claims to be, and the files the old validation refused would +// otherwise be the ones that now authorize their projects (Copilot on #765). +// +// A key that is absent is a file written since the paths went, and is left +// alone. +// +// The exact lookup is the complete one, and only because UnmarshalJSON walks +// the entry's keys a few lines above this: encoding/json matches a struct tag +// without regard to case, so {"Path": "../x"} fills LegacyPath while a lookup +// of "path" sees an absent key and validates nothing (Copilot on #765). The +// walk refuses every spelling but this one, so by here there is one. +// +// That precondition is established inside the same function that calls this, +// not in another package's call order, and TestAProjectEntryFailsClosedOnItsOwn +// decodes a bare {"Path":null} to hold it. Scanning case-insensitively here +// as well was tried and taken back out: after the walk the branch cannot run, +// and unreachable code with a comment claiming it is load-bearing is the +// stale description this branch spent a third of its rounds deleting. +func checkLegacyPath(data []byte) error { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return err + } + raw, ok := probe["path"] + if !ok { + return nil + } + return checkLegacyPathValue(raw) +} + +func checkLegacyPathValue(raw json.RawMessage) error { + if string(bytes.TrimSpace(raw)) == "null" { + return errors.New(`the "path" of a connector that routed projects is null; no such connector wrote that`) + } + var legacy string + if err := json.Unmarshal(raw, &legacy); err != nil { + return fmt.Errorf(`the "path" of a connector that routed projects is not a string: %w`, err) + } + // Before any check of the value: is this the value that is in the file? + // encoding/json substitutes U+FFFD for malformed UTF-8 and for an + // unpaired surrogate escape, silently and without an error, so + // "/work/\ud800" arrives here as "/work/\ufffd" — absolute, clean, and + // nothing the old writer could have produced (Copilot on #765). + // + // This is the second time on this branch that the decoder handed this + // check a different value from the one on disk. The first was + // case-folding a key looked up exactly; this is rewriting bytes + // validated afterwards. Both times the validation was right about the + // value it saw, and the value it saw was not the one in the file. + if lossyJSONString(bytes.TrimSpace(raw)) { + return errors.New(`the "path" of a connector that routed projects is not encoded the way it was written: it holds malformed UTF-8 or an unpaired surrogate escape, which encoding/json silently replaces with U+FFFD, so the value read back is not the value in the file`) + } + // POSIX, not this host's rules. The connector runs on Linux only, so the + // writer of this key wrote a POSIX path — and path.IsAbs answers for + // that format on every platform, where filepath.IsAbs answers for + // whatever the reader happens to be compiled for. This package builds + // everywhere, so with filepath a legitimate Linux-written connect.json + // would be refused on Windows: the check would reject exactly the files + // it exists to accept (Copilot on #765). + // + // A compatibility check validates what the old writer could produce, not + // what this reader would accept. Those are the same thing only when the + // format and the host agree, and a path is where they do not. + if strings.ContainsRune(legacy, 0) { + // A NUL is the one byte a filesystem path cannot hold: a path + // reaches the kernel as a NUL-terminated string, so os.Stat on this + // value is "invalid argument" and no connector could have written + // it. path.IsAbs and path.Clean are both content-blind and take it + // happily (Copilot on #765). + // + // Only a NUL. A Linux path may hold a newline, a tab or an escape, + // and those are bytes a connector really could have written — + // refusing them would be new strictness rather than a restored + // check, which is a mistake this branch has already been shown once. + return errors.New(`the "path" of a connector that routed projects contains a NUL, written \u0000 in JSON, which no filesystem path can hold`) + } + if legacy == "" || !path.IsAbs(legacy) || path.Clean(legacy) != legacy { + return fmt.Errorf(`the "path" of a connector that routed projects is %q, which is not the clean absolute POSIX path such a connector wrote`, legacy) + } + return nil } // Policy is what admission reads from connect.json, plus the two facts that @@ -58,14 +221,37 @@ type Policy struct { Buckets []int64 `json:"-"` Trust Trust `json:"trust"` - // Projects maps a bucket id to its route. - Projects map[int64]Route `json:"projects,omitempty"` + // Projects maps a bucket id to the entry for the project it serves. + Projects map[int64]Project `json:"projects,omitempty"` + // ProjectsUnknown says the served projects could not be read at all — + // connect.json is unreadable, or now names another agent. It is not the + // same as serving none, and the difference is what a person is told: a + // record whose answer turns on the list is held as a configuration + // error, rather than answered with a claim about the project that is + // false while the file is broken. + ProjectsUnknown bool `json:"-"` } // ParsePolicy decodes the admission part of connect.json. Unknown keys are -// ignored: the file carries settings that belong to other steps. The caller -// sets AgentID and Buckets, then calls Validate. +// ignored: the file carries settings that belong to other steps, and this +// reader has no business refusing a file over the driver's half of it. The +// caller sets AgentID and Buckets, then calls Validate. +// +// Noncanonical and repeated keys are refused, which is not the same +// laxness. An unknown key cannot reach anything this decides from; a key +// spelled "Trust" or "PATH" reaches exactly that, because encoding/json +// matches it. Ignoring one and refusing the other is the difference between +// tolerating a setting that is not ours and accepting an authorization we +// cannot see. See CheckCanonicalKeys. func ParsePolicy(data []byte) (Policy, error) { + // Not made redundant by the one in Project.UnmarshalJSON, which sees a + // single entry. This walks the whole document, and everything that + // authorizes outside an entry is only here: "Trust" beside "trust", + // "Projects" beside "projects", a project id written "01", any of those + // keys given twice. An entry-level walk cannot see one of them. + if err := CheckCanonicalKeys(data); err != nil { + return Policy{}, fmt.Errorf("admission: parse connect.json: %w", err) + } var p Policy if err := json.Unmarshal(data, &p); err != nil { return Policy{}, fmt.Errorf("admission: parse connect.json: %w", err) @@ -107,12 +293,9 @@ func (p Policy) Validate() error { default: return fmt.Errorf("admission: unknown trust mode %q", p.Trust.Mode) } - for bucket, route := range p.Projects { + for bucket := range p.Projects { if bucket <= 0 { - return fmt.Errorf("admission: route for bucket %d: not a bucket id", bucket) - } - if route.Path == "" { - return fmt.Errorf("admission: route for bucket %d has no path", bucket) + return fmt.Errorf("admission: served project %d: not a bucket id", bucket) } } return nil @@ -123,8 +306,95 @@ func (p Policy) inScope(bucket int64) bool { return len(p.Buckets) == 0 || slices.Contains(p.Buckets, bucket) } -// route returns the bucket's route, if connect.json has one. -func (p Policy) route(bucket int64) (Route, bool) { +// served returns the bucket's entry, if connect.json serves the project. +func (p Policy) served(bucket int64) (Project, bool) { r, ok := p.Projects[bucket] return r, ok } + +// lossyJSONString reports whether encoding/json had to alter raw, a JSON +// string token, to decode it — which it does by substituting U+FFFD, with no +// error, for exactly two things: bytes that are not valid UTF-8, and a +// \uD800-\uDFFF escape that is not half of a well-formed pair. Those are the +// whole of what it rewrites in a string, so testing for them is a test of +// losslessness and not a list of characters to dislike. +// +// Comparing raw against json.Marshal of the decoded value is the tempting +// general form, and it is wrong here. json.Marshal HTML-escapes & < and >, +// so it spells /work/r&d as "/work/r&d": a hand-edited connect.json +// with a literal ampersand — an ordinary thing in a directory name — would +// fail a canonical comparison and take the whole file down with it, over a +// field this code exists to discard. Whether the canonical form even has the +// escape depends on the encoder's SetEscapeHTML, which makes "canonical" two +// different answers. Refusing a legitimate path is the new-strictness +// mistake this branch has already made once; the lossy encoding is the +// actual complaint, so that is what this asks about. +// +// A path that genuinely contains U+FFFD still parses, written either as its +// literal UTF-8 bytes or as �, because neither is something the decoder +// had to replace. +func lossyJSONString(raw []byte) bool { + if !utf8.Valid(raw) { + return true + } + for i := 0; i < len(raw); { + if raw[i] != '\\' { + i++ + continue + } + if i+1 >= len(raw) { + // Malformed, and not this check's to report: the decode above + // already refused it. + return false + } + if raw[i+1] != 'u' { + i += 2 // \" \\ \/ \b \f \n \r \t: two bytes, neither a surrogate. + continue + } + r, ok := hex4(raw[i+2:]) + if !ok { + return false + } + i += 6 + if r < 0xD800 || r > 0xDFFF { + continue + } + if r >= 0xDC00 { + return true // a low surrogate with no high half before it + } + if i+5 < len(raw) && raw[i] == '\\' && raw[i+1] == 'u' { + if lo, ok := hex4(raw[i+2:]); ok && lo >= 0xDC00 && lo <= 0xDFFF { + i += 6 // a well-formed pair + continue + } + } + return true // a high surrogate its low half never follows + } + return false +} + +// hex4 reads the four hex digits of a \u escape. Decoded by hand rather than +// through strconv: JSON's escape grammar is exactly four hex digits, where +// ParseUint would take spellings that grammar does not, and four digits +// cannot exceed 0xFFFF so there is no width to lose. +func hex4(b []byte) (rune, bool) { + if len(b) < 4 { + return 0, false + } + var r rune + for _, c := range b[:4] { + var d rune + switch { + case c >= '0' && c <= '9': + d = rune(c - '0') + case c >= 'a' && c <= 'f': + d = rune(c-'a') + 10 + case c >= 'A' && c <= 'F': + d = rune(c-'A') + 10 + default: + return 0, false + } + r = r<<4 | d + } + return r, true +} diff --git a/internal/connector/admission/policy_test.go b/internal/connector/admission/policy_test.go index 1769448d1..8502055bf 100644 --- a/internal/connector/admission/policy_test.go +++ b/internal/connector/admission/policy_test.go @@ -1,6 +1,7 @@ package admission import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +13,13 @@ func TestParsePolicy(t *testing.T) { "driver": "spawn", "trust": {"operator_id": 26909558}, "projects": { - "48699913": {"path": "/work/connector", "class": "internal", "watch_completions": true}, - "555": {"path": "/work/other"} + "48699913": {"class": "internal", "watch_completions": true}, + "555": {} } }`)) require.NoError(t, err) assert.Equal(t, TrustOperator, p.Trust.Mode, "operator is the default mode") - assert.Equal(t, Route{Path: "/work/connector", Class: "internal", WatchCompletions: true}, p.Projects[48699913]) + assert.Equal(t, Project{Class: "internal", WatchCompletions: true}, p.Projects[48699913]) assert.False(t, p.Projects[555].WatchCompletions) p.AgentID = agentID @@ -35,9 +36,8 @@ func TestValidateFailsClosed(t *testing.T) { "non-positive allowlist id": func(p *Policy) { p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{0}} }, - "route without a path": func(p *Policy) { p.Projects[routedProj] = Route{Class: "internal"} }, - "route for a non-bucket": func(p *Policy) { p.Projects[-1] = Route{Path: "/x"} }, - "agent as operator": func(p *Policy) { p.Trust.OperatorID = agentID }, + "served project that is not a bucket": func(p *Policy) { p.Projects[-1] = Project{} }, + "agent as operator": func(p *Policy) { p.Trust.OperatorID = agentID }, "agent in the allowlist": func(p *Policy) { p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{agentID}} }, @@ -56,3 +56,251 @@ func TestValidateFailsClosed(t *testing.T) { } require.NoError(t, basePolicy().Validate()) } + +// Copilot on #765: connect.json is the trust anchor, so a malformed entry +// must refuse rather than authorize. +// +// The path check that went with the routes was doing two jobs and looked +// like one. It required a path, and because a JSON null decodes into a +// struct as the zero value without an error, a null entry had no path and so +// was refused. Take the path away and the null becomes a perfectly valid +// served project: a half-edited file, a bad merge or a truncated write would +// grant authorization where it used to withhold it. +func TestANullProjectEntryIsRefusedRatherThanServed(t *testing.T) { + _, err := ParsePolicy([]byte(`{"trust":{"mode":"operator","operator_id":26909558},"projects":{"48699913":null}}`)) + require.Error(t, err, "a null entry is not a served project") + assert.Contains(t, err.Error(), "{}", "and the refusal says what a valid entry looks like") + + // An empty object is a served project with no settings, and stays one: + // this refuses the null, not the absence of settings. + p, err := ParsePolicy([]byte(`{"trust":{"mode":"operator","operator_id":26909558},"projects":{"48699913":{}}}`)) + require.NoError(t, err) + assert.Contains(t, p.Projects, int64(48699913)) +} + +// Copilot on #765: a field kept for compatibility is still load-bearing +// while it is being read. +// +// LegacyPath is decoded and thrown away, and discarding a value is not the +// same as not caring what it is. The old writer could only ever have put a +// clean absolute path there, so anything else means the file is not what it +// claims to be — and the old validation refused exactly those files. Reading +// the key without its shape check would let the files that used to be +// refused authorize their projects instead. +func TestAMalformedLegacyPathIsRefusedBeforeItIsDiscarded(t *testing.T) { + policy := func(entry string) error { + _, err := ParsePolicy([]byte(`{"trust":{"mode":"operator","operator_id":26909558},"projects":{"48699913":` + entry + `}}`)) + return err + } + for name, entry := range map[string]string{ + "null": `{"path":null}`, + "empty": `{"path":""}`, + "relative": `{"path":"work/app"}`, + "unclean": `{"path":"/work/../etc"}`, + "not a string": `{"path":42}`, + // A filesystem path cannot hold a NUL: the kernel refuses one + // outright, since a path reaches it as a NUL-terminated string. So + // no connector ever wrote this, whatever path.IsAbs and path.Clean + // make of it — both are happy with it. + "a NUL byte": `{"path":"/work/\u0000app"}`, + } { + t.Run(name, func(t *testing.T) { + assert.Error(t, policy(entry), "the old validation refused this file, and so must reading it") + }) + } + + // Only a NUL, though. A Linux path may hold a newline, a tab or an + // escape — those are bytes a connector really could have written, and + // refusing them would be new strictness rather than a restored check, + // which is the mistake this branch already made once elsewhere. + require.NoError(t, policy(`{"path":"/work/a\nb"}`), "a newline is legal in a path, so a writer could have produced it") + require.NoError(t, policy(`{"path":"/work/a\tb"}`), "and a tab") + + // The shape is the writer's, not this host's. A connector runs on Linux + // only, so this key is a POSIX path — and this package builds + // everywhere, so a check written against the reader's platform would + // refuse a legitimate Linux-written file on Windows, rejecting exactly + // what it exists to accept. This test file carries no build tag on + // purpose: it is the one that would catch that, and it catches it only + // where it runs. + require.NoError(t, policy(`{"path":"/work/app"}`), + "a POSIX absolute path is the shape the writer wrote, on every platform this parser is built for") + assert.Error(t, policy(`{"path":"C:\\work\\app"}`), + "and a path in the reader's dialect is not something that writer could have produced") + + // What the old writer really wrote still opens, and the key is still + // discarded rather than kept. + p, err := ParsePolicy([]byte(`{"trust":{"mode":"operator","operator_id":26909558},"projects":{"48699913":{"path":"/work/app","class":"internal"}}}`)) + require.NoError(t, err) + assert.Equal(t, "internal", p.Projects[48699913].Class) + + // And a new file, which carries no path at all, is unaffected. + require.NoError(t, policy(`{"class":"internal"}`)) + require.NoError(t, policy(`{}`)) +} + +// Copilot on #765: encoding/json matches a struct tag case-insensitively, so +// a value under "Path" lands in LegacyPath while checkLegacyPath, which looks +// the key up exactly, sees nothing to check. The reported case is one of six. +// +// This goes through ParsePolicy on purpose. The same documents were already +// covered through setup.Parse, which has the key walk and refused every one +// of them — and that coverage is what hid the gap, because the reader that +// authorizes is the other one. A test of the strict reader says nothing +// about the permissive one. +func TestParsePolicyRefusesKeysTheDecoderWouldMatchAnyway(t *testing.T) { + const trust = `"trust":{"mode":"operator","operator_id":26909558}` + for name, tc := range map[string]struct { + doc string + would string + }{ + "trust under a case variant": { + doc: `{` + trust + `,"Trust":{"mode":"allowlist","allowlist_ids":[999]},"projects":{"1":{}}}`, + would: "escalate the trust mode to allowlist and trust person 999", + }, + "projects under a case variant": { + doc: `{` + trust + `,"projects":{"1":{}},"Projects":{"2":{}}}`, + would: "serve project 2, which the projects key does not name", + }, + "project id with a leading zero": { + doc: `{` + trust + `,"projects":{"01":{}}}`, + would: "serve project 1 under a key setup refuses", + }, + "legacy path under a case variant": { + doc: `{` + trust + `,"projects":{"1":{"Path":null}}}`, + would: "serve project 1 with the legacy-path check skipped entirely", + }, + "legacy path under an upper-case variant": { + doc: `{` + trust + `,"projects":{"1":{"PATH":"../elsewhere"}}}`, + would: "serve project 1 carrying a relative path no routing connector wrote", + }, + "a key given twice": { + doc: `{` + trust + `,"projects":{"1":{}},"projects":{"2":{}}}`, + would: "serve project 2 while the file appears to say project 1", + }, + } { + t.Run(name, func(t *testing.T) { + p, err := ParsePolicy([]byte(tc.doc)) + require.Error(t, err, "accepted; it would %s", tc.would) + assert.Equal(t, Policy{}, p, "a refused document yields no policy") + }) + } + + // The control. Every document above is one key's spelling away from a + // document that parses, so the refusals are the spelling and not some + // other thing wrong with them — without this the table would pass just + // as well against a ParsePolicy that refused everything. + p, err := ParsePolicy([]byte(`{` + trust + `,"projects":{"1":{},"2":{"path":"/srv/app"}}}`)) + require.NoError(t, err) + assert.Len(t, p.Projects, 2) + assert.Equal(t, TrustOperator, p.Trust.Mode) +} + +// Project is exported and its UnmarshalJSON runs wherever a caller decodes +// an entry — setup's own refuseMalformedProjects does exactly that — so it +// has to fail closed on an entry nobody walked first. A guarantee that holds +// only because two functions happen to run in one order is one a later edit +// removes without touching either of them, which is why the walk is inside +// the component and this test decodes bare entries rather than documents. +// +// It covers the whole entry, not the legacy path alone. DisallowUnknownFields +// refuses a name this type does not have and nothing else: a name it does +// have, spelled in another case or given twice, decodes quietly and the last +// one wins (Copilot on #765). +func TestAProjectEntryFailsClosedOnItsOwn(t *testing.T) { + for name, entry := range map[string]string{ + "a case variant of path": `{"Path":null}`, + "an upper-case variant": `{"PATH":"../elsewhere"}`, + "a mixed-case variant": `{"pAtH":"work/app"}`, + "the canonical spelling still": `{"path":""}`, + + // DisallowUnknownFields refuses a name this type does not have. It + // does not refuse a name it does have, spelled differently or given + // twice: the decoder matches case-insensitively and keeps the last + // of a repeated key, so each of these decoded cleanly and the entry + // did not say what it read as (Copilot on #765). + "a known field in another case": `{"WATCH_COMPLETIONS":true}`, + "a known field capitalized": `{"Class":"internal"}`, + "a field given twice": `{"watch_completions":true,"watch_completions":false}`, + "a field given twice by case": `{"class":"a","CLASS":"b"}`, + "a legacy path beside its variant": `{"path":"/work/app","Path":"/work/app"}`, + } { + t.Run(name, func(t *testing.T) { + var p Project + assert.Error(t, json.Unmarshal([]byte(entry), &p), + "the decoder reads this entry as settings a check never sees under that spelling, so the entry does not say what it reads as") + }) + } + + // Unchanged for everything that was already right: the value check reads + // the value, and a well-formed one under any spelling is still a + // well-formed value. What refuses the spelling is the document walk. + var p Project + require.NoError(t, json.Unmarshal([]byte(`{"path":"/work/app","class":"internal"}`), &p)) + assert.Equal(t, "internal", p.Class) + + // And the settings an entry really carries still decode, each once, in + // their own spelling. This is the control: without it the table above + // would pass just as well against an UnmarshalJSON that refused every + // entry it was given. + var q Project + require.NoError(t, json.Unmarshal([]byte(`{"class":"internal","watch_completions":true}`), &q)) + assert.Equal(t, Project{Class: "internal", WatchCompletions: true}, q) +} + +// Copilot on #765, the fourth fail-open in this one shim: encoding/json +// substitutes U+FFFD for malformed UTF-8 and for an unpaired surrogate +// escape, silently, so "/work/\ud800" reaches the shape checks as +// "/work/�" — absolute, clean, and authorizing. +// +// Same mechanism as the case-variant keys, a different decoder behavior: +// there it folded a key looked up exactly, here it rewrites bytes validated +// afterwards. The check has to ask whether the value it is about to validate +// is the value in the file, before it validates anything. +func TestALegacyPathTheDecoderHadToRewriteIsRefused(t *testing.T) { + policy := func(entry string) error { + _, err := ParsePolicy([]byte(`{"trust":{"mode":"operator","operator_id":26909558},"projects":{"48699913":` + entry + `}}`)) + return err + } + for name, entry := range map[string]string{ + "an unpaired high surrogate": `{"path":"/work/\ud800"}`, + "an unpaired low surrogate": `{"path":"/work/\udc00"}`, + "a high surrogate then text": `{"path":"/work/\ud800app"}`, + "a reversed surrogate pair": `{"path":"/work/\udc00\ud800"}`, + "raw malformed UTF-8": "{\"path\":\"/work/\xff\"}", + "a truncated UTF-8 sequence": "{\"path\":\"/work/\xe2\x82\"}", + "malformed bytes alone": "{\"path\":\"\xc3\x28\"}", + } { + t.Run(name, func(t *testing.T) { + assert.Error(t, policy(entry), "the decoder replaced this with U+FFFD, so the checks ran on a value no writer wrote") + }) + } + + // A well-formed pair is not lossy, and neither is a path that really + // holds U+FFFD. Reject the lossy input, not the character. + require.NoError(t, policy(`{"path":"/work/😀"}`), "a well-formed surrogate pair is an emoji, not a replacement") + require.NoError(t, policy(`{"path":"/work/�"}`), "U+FFFD written as an escape is a path a writer could hold") + require.NoError(t, policy("{\"path\":\"/work/\\u00e9\"}"), "an ordinary escaped rune") + require.NoError(t, policy("{\"path\":\"/work/�\"}"), "and U+FFFD as its own literal bytes") + + // The reason this is a losslessness test and not a comparison against + // json.Marshal of the decoded value. json.Marshal HTML-escapes & < and + // >, so it spells these three with &, < and > and a + // canonical comparison would refuse every one of them — ordinary + // directory names, in a file this code is only reading in order to throw + // the field away. Refusing a legitimate path is the mistake, not the + // miss. + require.NoError(t, policy(`{"path":"/work/r&d"}`), "an ampersand is an ordinary character in a directory name") + require.NoError(t, policy(`{"path":"/work/ay"}`)) + // And the same three as the writer itself spells them, which is the + // other half of why the comparison has two answers. + require.NoError(t, policy(`{"path":"/work/r&d"}`), "the spelling json.Marshal produces parses too") + require.NoError(t, policy(`{"path":"/work/a` once + // the file is back — as every blocked record does. Nothing + // re-offers one on a timer. + return v.end(StateBlocked, ReasonConfigUnreadable), nil + } + // Every other gate discard turns on the trust set, the matrix or the + // --project scope, none of which the unreadable file touches. return v.end(StateDiscarded, gate.Reason), nil } + if policy.ProjectsUnknown { + // Before any read, and before anything below can end the verdict: + // nothing here can decide an event whose answer turns on a list that + // could not be read, and a discard is the one outcome repairing the + // file cannot reverse. + // + // It has to be here rather than after the trigger rules. A + // comment.created admits under mentioned, which needs no served + // project, or subscribed, which does — so with the list unknown the + // gate keeps the first and drops the second, does not discard, and + // match then returns not_addressed for a comment the agent is + // subscribed to. Terminally, over a broken file (Copilot on #765). + // + // Costing no reads is the other half: an unreadable file stops the + // connector spending the account's API budget on events it cannot + // decide. + return v.end(StateBlocked, ReasonConfigUnreadable), nil + } if gate.ConfirmMembership { member, reason, err := d.memberOf(ctx, ev.BucketID, ev.Performer(), ev.SeenAt) @@ -256,11 +336,11 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error } v.RecordingURL = summary.AppURL - if route, ok := a.policy.route(ev.BucketID); ok { - v.Routed, v.Route, v.Class = true, route.Path, route.Class + if project, ok := policy.served(ev.BucketID); ok { + v.Served, v.Class = true, project.Class } - rule, state, reason, err := d.match(ctx, ev, gate.Rules, summary) + rule, state, reason, err := d.match(ctx, ev, policy, gate.Rules, summary) if err != nil { return Verdict{}, err } @@ -271,11 +351,12 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error v.Trigger, v.Acknowledge = rule.Trigger, rule.Acknowledge v.address(summary) - if !v.Routed { - // Mentioned and assigned are answered in an unmapped project rather + if !v.Served { + // Mentioned and assigned are answered in an unserved project rather // than dropped: the record keeps its trigger and reply destination - // for the holding reply, and is read again when a route appears. The - // gate already discarded every trigger that requires a route. + // for the holding reply, and is read again once the operator serves + // the project. The gate already discarded every trigger that requires + // a served project. return v.end(StateBlocked, ReasonNoRoute), nil } v.State = StateAdmitted @@ -291,7 +372,7 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error // match tries the gate's open rules in matrix order and returns the first // that admits, or the state and reason that end the event. -func (a *decision) match(ctx context.Context, ev Event, rules []Rule, summary *basecamp.RecordingSummary) (Rule, State, Reason, error) { +func (a *decision) match(ctx context.Context, ev Event, policy Policy, rules []Rule, summary *basecamp.RecordingSummary) (Rule, State, Reason, error) { agent := a.policy.AgentID mentioned := slices.Contains(summary.MentionedPersonIDs, agent) endState, endReason := StateDiscarded, ReasonNotAddressed @@ -366,8 +447,8 @@ func (a *decision) match(ctx context.Context, ev Event, rules []Rule, summary *b } case TriggerCompleted: - route, routed := a.policy.route(ev.BucketID) - if routed && route.WatchCompletions { + project, served := policy.served(ev.BucketID) + if served && project.WatchCompletions { return rule, "", "", nil } if assigned(summary, agent) { diff --git a/internal/connector/dispatcher.go b/internal/connector/dispatcher.go index 5e5f4a25d..004b9dbd4 100644 --- a/internal/connector/dispatcher.go +++ b/internal/connector/dispatcher.go @@ -32,9 +32,12 @@ import ( // driver is asked for anything, a follow-up is exposed before its prompt // is sent, and an attempt is ended in the ledger only after its worker is // gone. -// 2. The directory is the record's. A worker runs only in the route the -// record carries, and only while connect.json still approves that route -// for the record's project. +// 2. The project is connect.json's. A worker runs only for a record whose +// project connect.json still serves, and stops being handed follow-ups +// the moment it stops serving it. Where the worker runs is not the +// record's: every task runs in the directory the connector was started +// in, and a task that needs a clone or a directory of its own is the +// agent's business to make. // 3. Nothing crosses to a worker that it does not need. The prompt names // events and a recording URL, never content, and is under // MaxPromptTokens at its worst case; the task token reaches only the @@ -74,12 +77,20 @@ type DispatcherOptions struct { Ledger *Ledger // Driver starts workers. Driver driver.Driver - // Routes is connect.json's current routes by project. - Routes func() map[int64]admission.Route + // Served is connect.json's served projects as they are now, by project + // id, and the reason they could not be read when that is the answer. + // + // A failure is not an empty map. Everything that decides on this set + // fails closed either way, but the two are different things to say out + // loud, and one consumer says something: reportStranded. Telling an + // operator that their projects are no longer served, when what happened + // is that nothing could read the file, is the same false claim the + // holding reply used to make on a card (Copilot on #765). + Served func() (map[int64]admission.Project, error) // TokenWindow is how long a task token's socket waits for the worker's // MCP server; DefaultTokenWindow when zero. TokenWindow time.Duration - // Buckets is the --project scope; empty means every routed project. + // Buckets is the --project scope; empty means every served project. Buckets []int64 // Concurrency is the most live tasks; setup's default when zero. Concurrency int @@ -92,14 +103,17 @@ type DispatcherOptions struct { // MCP names what the worker's Basecamp MCP server runs as. MCP WorkerMCP - // Policy is the permission policy; DefaultPolicy for the working - // directory when nil. - Policy func(workDir string) driver.PermissionPolicy + // Policy is the permission policy; DefaultPolicy when nil. + Policy func() driver.PermissionPolicy // Lookup reads the connector's environment for the allowlists; // os.LookupEnv when nil. Lookup func(string) (string, bool) // PrivateDir is an owner-only directory for session files. PrivateDir string + // WorkDir is where every worker runs: the directory the connector was + // started in, os.Getwd() when empty. It is where the process starts, not + // a bound on where it may write — nothing here bounds that. + WorkDir string // Replies, when set, is read for the adopted-reply rule. Replies ReplyLister @@ -191,8 +205,8 @@ func NewDispatcher(opts DispatcherOptions) (*Dispatcher, error) { return nil, errors.New("connector: the dispatcher needs the ledger") case opts.Driver == nil: return nil, errors.New("connector: the dispatcher needs a driver") - case opts.Routes == nil: - return nil, errors.New("connector: the dispatcher needs connect.json's routes") + case opts.Served == nil: + return nil, errors.New("connector: the dispatcher needs connect.json's served projects") case opts.MCP.Command == "" || opts.MCP.Profile == "" || opts.MCP.StateDir == "": return nil, errors.New("connector: the dispatcher needs the worker's MCP server command, profile and state directory") case opts.PrivateDir == "": @@ -205,7 +219,16 @@ func NewDispatcher(opts DispatcherOptions) (*Dispatcher, error) { opts.Launcher = driver.DirectLauncher{} } if opts.Policy == nil { - opts.Policy = func(workDir string) driver.PermissionPolicy { return DefaultPolicy(workDir) } + opts.Policy = func() driver.PermissionPolicy { return DefaultPolicy() } + } + if opts.WorkDir == "" { + // The connector runs where it was started, and so does every worker + // it starts. A directory it cannot name is one no driver could open. + wd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("connector: the dispatcher needs the directory it was started in: %w", err) + } + opts.WorkDir = wd } if opts.Lookup == nil { opts.Lookup = os.LookupEnv @@ -288,8 +311,8 @@ func (d *Dispatcher) Run(ctx context.Context) error { // process's attempt half-settled, with a worker ended and its record still // live (Copilot on #738). So what recovery reads and what it settles go on a // context cancellation does not reach, as every other settlement does -// (settleCtx). Only the working directories' own reconciliation, which -// settles nothing, is left on the caller's context. +// (settleCtx). Only the private directory's own sweep, which settles +// nothing, is left on the caller's context. func (d *Dispatcher) Recover(ctx context.Context) error { cleanupCtx := context.WithoutCancel(ctx) d.sweepPrivateDir() @@ -307,9 +330,11 @@ func (d *Dispatcher) Recover(ctx context.Context) error { // Launching with no process recorded: the crash fell between the // spawn and the write, so a worker may exist that cannot be // named. Treated as running (the spec's rule) means it is not - // settled around either: its attempt stays live and its - // conversation and directory stay held. - d.log.Error("connector: an attempt was left mid-launch and its worker cannot be identified; it stays live and its directory held", + // settled around either: its attempt stays live, so its + // conversation stays held and it goes on taking a worker slot. + // It holds no directory: every worker runs where the connector + // was started. + d.log.Error("connector: an attempt was left mid-launch and its worker cannot be identified; it stays live, holding its conversation and a worker slot", "attempt_id", a.AttemptID, "task_id", a.TaskID) d.hold() continue @@ -319,9 +344,9 @@ func (d *Dispatcher) Recover(ctx context.Context) error { if err != nil { // A worker that may still be running with the operator's // authority is not settled around. Its attempt stays live, so its - // conversation and its directory stay held and nothing new runs - // there, until a person has looked. - d.log.Error("connector: could not verify whether a previous worker still runs; its attempt stays live and its directory held", + // conversation stays held and nothing new runs on it, until a + // person has looked. + d.log.Error("connector: could not verify whether a previous worker still runs; its attempt stays live and its conversation held", "attempt_id", a.AttemptID, "pid", a.Process.PID, "error", err) d.hold() continue @@ -330,7 +355,7 @@ func (d *Dispatcher) Recover(ctx context.Context) error { "task_id", a.TaskID, "was", string(a.State), "worker_signaled", signaled) // Through the one release point, which confirms the group is gone // before anything is settled or released. - d.release(cleanupCtx, Launch{TaskID: a.TaskID, AttemptID: a.AttemptID, Route: a.Route, WorkDir: a.WorkDir}, + d.release(cleanupCtx, Launch{TaskID: a.TaskID, AttemptID: a.AttemptID}, worker, TokenHolder{Process: a.Taker.Identity(), Unaccounted: a.TakerUnaccounted}, AttemptEnd{AttemptID: a.AttemptID, Stop: StopLost}, nil) } @@ -395,14 +420,21 @@ func (d *Dispatcher) dispatchReady(ctx context.Context) error { } d.mu.Unlock() - approved := d.approvedRoutes() + served, servedErr := d.servedBuckets() // Follow-ups first: an event on a live conversation joins its task, while - // connect.json still approves that task's directory for its project. + // connect.json still serves that task's project. The served set goes to + // the ledger as well as being checked here, so what joins is held to the + // task's own project and to the set as it is now — not to the served bit + // admission wrote on each record when it decided it. for _, r := range runs { - if !r.authorized() { + // The same snapshot decides and is handed to the join. Reading it + // again inside authorized would let the cache turn over between the + // two, so a task could be authorized against one set and joined + // against another (Copilot on #765). + if servedErr != nil || !r.authorizedIn(served) { continue } - if _, err := d.ledger.JoinConversation(ctx, r.launch.TaskID); err != nil { + if _, err := d.ledger.JoinConversation(ctx, r.launch.TaskID, served); err != nil { return err } } @@ -414,16 +446,16 @@ func (d *Dispatcher) dispatchReady(ctx context.Context) error { if d.free() <= 0 { return nil } - // Invariant 2, in the query: only records whose route connect.json - // approves now, in the projects this run hears, and on a directory no live - // task holds. A record the dispatcher cannot start never fills the window. + // Invariant 2, in the query: only records in a project this pass read as + // served, among the projects this run hears. A record the dispatcher + // cannot start never fills the window. records, err := d.ledger.StartableRecordsWhere(ctx, StartableFilter{ - Routes: approved, RouteHeld: true, Limit: d.opts.Concurrency * 4, + Served: served, Limit: d.opts.Concurrency * 4, }) if err != nil { return err } - d.reportStranded(ctx, approved) + d.reportStranded(ctx, served, servedErr) for _, record := range records { // Asked again on every record, not counted down: a start that failed // can have held its attempt, and a held attempt takes a slot as a @@ -431,10 +463,7 @@ func (d *Dispatcher) dispatchReady(ctx context.Context) error { if d.free() <= 0 { break } - if d.workDirBusy(record.Decision.Route) { - continue - } - if err := d.start(ctx, record); err != nil { + if err := d.start(ctx, record, served); err != nil { if errors.Is(err, ErrNotStartable) { continue } @@ -455,61 +484,78 @@ func (d *Dispatcher) free() int { } // StrandedInterval is how often the dispatcher says how much admitted work -// no route of connect.json's covers. +// sits in a project connect.json no longer serves. const StrandedInterval = 10 * time.Minute -// reportStranded counts the records waiting for a worker that no approved -// route covers — a project unrouted, or its route changed since the record -// was admitted — and says so, rather than leaving them silently unstarted. -func (d *Dispatcher) reportStranded(ctx context.Context, approved map[int64]string) { +// reportStranded counts the records waiting for a worker in a project this +// connector no longer serves — one the operator has taken out of connect.json +// since the record was admitted — and says so, rather than leaving them +// silently unstarted. +func (d *Dispatcher) reportStranded(ctx context.Context, served []int64, servedErr error) { if time.Since(d.strandedAt) < StrandedInterval { return } d.strandedAt = time.Now() - stranded, err := d.ledger.StrandedRecords(ctx, approved, d.opts.Buckets) + if servedErr != nil { + // Nothing read which projects are served, so nothing here can say a + // record's project is not among them. Counting against an empty set + // would call every startable record stranded and tell the operator + // to serve or discard projects that may be served already. + // + // And it says only what is known. Whether any record is waiting is + // the question this cannot answer either — the count that would + // answer it is the one being skipped, and the ledger may be empty + // (Copilot on #765). What is certain is that nothing will start. + d.log.Warn("connector: dispatch is paused: which projects are served could not be read", "error", servedErr) + return + } + stranded, err := d.ledger.StrandedRecords(ctx, served, d.opts.Buckets) if err != nil { d.log.Warn("connector: counting stranded records", "error", err) return } if stranded > 0 { - d.log.Warn("connector: admitted work no route covers is waiting; route its project or discard it", + d.log.Warn("connector: admitted work in a project this connector no longer serves is waiting; serve its project or discard it", "records", stranded) } } -// approvedRoutes is connect.json's routes now, narrowed to the projects this -// run hears. -func (d *Dispatcher) approvedRoutes() map[int64]string { - approved := map[int64]string{} - for bucket, route := range d.opts.Routes() { - if len(d.opts.Buckets) == 0 || slices.Contains(d.opts.Buckets, bucket) { - approved[bucket] = route.Path - } +// servedBuckets is the served projects as the reader has them — a fresh read +// or one reused within its TTL, never a lock — narrowed to the ones this run +// hears. One pass takes it once and hands it down, so everything that pass +// decides is decided from the same reading. +func (d *Dispatcher) servedBuckets() ([]int64, error) { + projects, err := d.opts.Served() + if err != nil { + // Nothing is authorized while the answer cannot be read. Every + // caller but reportStranded wants exactly that and nothing more, + // which is why the empty set and the error travel together. + return nil, err } - return approved -} - -func (d *Dispatcher) workDirBusy(route string) bool { - d.mu.Lock() - defer d.mu.Unlock() - for _, r := range d.live { - if r.launch.Route == route || r.launch.WorkDir == route { - return true + var served []int64 + for bucket := range projects { + if len(d.opts.Buckets) == 0 || slices.Contains(d.opts.Buckets, bucket) { + served = append(served, bucket) } } - return false + slices.Sort(served) + return served, nil } // start launches a task for record: the ledger first, then the driver, and // the release point on every path that fails after it. Capacity is the // caller's question (free), not this one's. -func (d *Dispatcher) start(ctx context.Context, record Record) error { - route := record.Decision.Route - // The working directory is the route, and nothing is prepared for it: the - // connector runs the worker where it was pointed, and a task that needs a - // directory of its own is the agent's business to make. +// +// served is the snapshot the record was chosen against, handed down rather +// than read again: one pass of the dispatcher decides from one reading of +// connect.json, as one verdict does (admission's policyNow). +func (d *Dispatcher) start(ctx context.Context, record Record, served []int64) error { + // Nothing is prepared and nothing is resolved: the worker runs where the + // connector was started, and a task that needs a clone or a directory of + // its own is the agent's business to make. launch, err := d.ledger.LaunchTask(ctx, LaunchSpec{ - EventID: record.ID, Route: route, WorkDir: route, Driver: d.opts.Driver.Name(), Deadline: d.opts.Deadline, + EventID: record.ID, Served: served, + Driver: d.opts.Driver.Name(), Deadline: d.opts.Deadline, }) if err != nil { return err @@ -655,7 +701,7 @@ func (d *Dispatcher) sessionConfig(ctx context.Context, launch Launch, record Re } } return driver.SessionConfig{ - Cwd: launch.WorkDir, + Cwd: d.opts.WorkDir, Env: driver.BuildEnv(driver.BaseEnv, d.opts.Lookup, nil), // The socket is armed the moment the worker's process exists, which // is inside NewSession and before whatever handshake the driver runs @@ -674,7 +720,7 @@ func (d *Dispatcher) sessionConfig(ctx context.Context, launch Launch, record Re "--connect-state", d.opts.MCP.StateDir, "--socket", tokens.Path()}, Env: serverEnv, }}, - Policy: d.opts.Policy(launch.WorkDir), + Policy: d.opts.Policy(), Launcher: d.opts.Launcher, // EventIDs are the task's events. Only the originating one has been // handed out at launch; the rest are exposed as they are prompted, so @@ -683,7 +729,7 @@ func (d *Dispatcher) sessionConfig(ctx context.Context, launch Launch, record Re SocketDir: socketDir, Scope: driver.Scope{ TaskID: launch.TaskID, AttemptID: launch.AttemptID, EventIDs: launch.EventIDs, - WorkDir: launch.WorkDir, SocketDir: socketDir, Class: record.Decision.Class, + WorkDir: d.opts.WorkDir, SocketDir: socketDir, Class: record.Decision.Class, }, PrivateDir: dir, }, tokens, cleanup, nil @@ -842,8 +888,8 @@ func (d *Dispatcher) confirmTakerGone(worker driver.Process, holder TokenHolder) // The one rule for a holder the connector cannot account for: the // token is out, nothing here can name the process that has it or // prove it has gone, and an attempt is never released around that. - // It stays live — its directory, its conversation and one worker - // slot with it — for a person to settle (Copilot on #738). + // It stays live — its conversation and one worker slot with it — + // for a person to settle (Copilot on #738). return errors.New("connector: this task's token was delivered and the process holding it cannot be accounted for") } taker := holder.Process @@ -884,9 +930,8 @@ const settleAttempts = 5 // // It settles nothing until the worker's process group is confirmed gone, and // nothing if the ledger refuses the settlement. Either way the attempt stays -// live: its token, its conversation and its directory are still its own, a -// person settles it, and this process stops counting it among the workers it -// may start. +// live: its token and its conversation are still its own, a person settles +// it, and this process goes on counting it among the workers it has. func (d *Dispatcher) release(ctx context.Context, launch Launch, worker driver.Process, holder TokenHolder, end AttemptEnd, run *taskRun) { log := d.taskLog(d.taskRedaction(launch, driver.SessionConfig{})) err := d.confirmGroupGone(worker, d.opts.CancelGrace) @@ -901,7 +946,7 @@ func (d *Dispatcher) release(ctx context.Context, launch Launch, worker driver.P if run != nil { d.forget(launch.AttemptID) } - log.Error("connector: the worker's process group is still alive; its attempt stays live, and its directory is not released", + log.Error("connector: the worker's process group is still alive; its attempt stays live, holding its conversation and a worker slot", "attempt_id", end.AttemptID, "task_id", launch.TaskID, "error", err) d.line(DispatchLine{Type: "dispatch", TaskID: launch.TaskID, AttemptID: end.AttemptID, State: string(AttemptRunning), StopReason: "held"}) return @@ -912,7 +957,7 @@ func (d *Dispatcher) release(ctx context.Context, launch Launch, worker driver.P if run != nil { d.forget(launch.AttemptID) } - log.Error("connector: could not settle an attempt; it stays live, and its directory is not released", + log.Error("connector: could not settle an attempt; it stays live, holding its conversation and a worker slot", "attempt_id", end.AttemptID, "task_id", launch.TaskID, "error", err) d.line(DispatchLine{Type: "dispatch", TaskID: launch.TaskID, AttemptID: end.AttemptID, State: string(AttemptRunning), StopReason: "held"}) return @@ -931,7 +976,7 @@ func (d *Dispatcher) release(ctx context.Context, launch Launch, worker driver.P } // settle ends an attempt in the ledger, retrying a failure with backoff: an -// attempt left live holds its token, conversation and directory. +// attempt left live holds its token, its conversation and a worker slot. func (d *Dispatcher) settle(ctx context.Context, end AttemptEnd) (Settlement, error) { backoff := 200 * time.Millisecond for i := 1; ; i++ { @@ -1089,7 +1134,7 @@ func (r *taskRun) supervise(ctx context.Context) { } // Through the one release point: it confirms the worker's group is gone - // before the attempt is settled or its directory released. + // before the attempt is settled. d.release(settleCtx, r.launch, r.session.Process(), taker, AttemptEnd{AttemptID: r.launch.AttemptID, Stop: stop, UnrecordedRefusals: unrecorded}, r) } @@ -1141,14 +1186,26 @@ func (r *taskRun) promptLoop(ctx context.Context, deadline, stillRunning <-chan // nextFollowUp exposes the next event on the task not yet handed to the // worker, and returns it. Nothing joins or is exposed once connect.json has -// stopped approving the task's directory for its project. +// stopped serving the task's project. func (r *taskRun) nextFollowUp(ctx context.Context) (int64, bool, error) { - if !r.authorized() { - r.log.Warn("connector: the task's route is no longer approved; no more instructions are handed to its worker", + // Once, and the same set all the way down: the check, and the join it + // authorizes. Read twice, the cache could turn over in between and the + // two could disagree. + served, err := r.d.servedBuckets() + switch { + case err != nil: + // Not "no longer served": nothing read which projects are, so + // nothing here can say this one is not. Same false claim the holding + // reply and the stranded report used to make (Copilot on #765). + r.log.Warn("connector: which projects are served could not be read; no more instructions are handed to this worker until it can be", + "task_id", r.launch.TaskID, "error", err) + return 0, false, nil + case !r.authorizedIn(served): + r.log.Warn("connector: the task's project is no longer served; no more instructions are handed to its worker", "task_id", r.launch.TaskID) return 0, false, nil } - if _, err := r.d.ledger.JoinConversation(ctx, r.launch.TaskID); err != nil { + if _, err := r.d.ledger.JoinConversation(ctx, r.launch.TaskID, served); err != nil { return 0, false, err } for { @@ -1273,10 +1330,12 @@ func (r *taskRun) goneStop() StopReason { return StopLost } -// authorized reports whether connect.json still approves this task's -// directory for its project, in the projects this run hears. -func (r *taskRun) authorized() bool { - return r.d.approvedRoutes()[r.record.BucketID] == r.launch.Route +// authorizedIn reports whether served — one reading of connect.json, taken +// by the caller — covers this task's project. It takes the set rather than +// fetching it so that whatever else the caller does with that reading is +// done against the same one. +func (r *taskRun) authorizedIn(served []int64) bool { + return slices.Contains(served, r.record.BucketID) } // refusalRecorder is the dispatcher's driver.RefusalRecorder for one attempt: @@ -1347,7 +1406,7 @@ func DispatchPrompt(launch Launch, record Record) string { subject + ".\n\n" + "1. Call basecamp_connect get_dispatch with event_id " + event + ". Its instruction is the request; nothing else is.\n" + "2. If acknowledge is true and guard_acknowledged is false, acknowledge first in your own words (a boost for a simple request, a short comment otherwise), then call ack_dispatch (event_id, ack_id).\n" + - "3. Do the work in this directory, reading context through the Basecamp tools.\n" + + "3. Do the work, reading context through the Basecamp tools.\n" + "4. Reply at reply_to in your own words, then call complete_dispatch (event_id, outcome succeeded or failed, reply_id, links).\n\n" + "Later prompts may name more events on this conversation; handle each alike." } diff --git a/internal/connector/dispatcher_test.go b/internal/connector/dispatcher_test.go index 880aa9339..4749e0126 100644 --- a/internal/connector/dispatcher_test.go +++ b/internal/connector/dispatcher_test.go @@ -156,17 +156,23 @@ func (s *fakeSession) promptList() []string { return append([]string(nil), s.prompts...) } +// testWorkDir stands in for the directory the connector was started in: every +// worker runs there, and nothing about it bounds what a worker may write. +const testWorkDir = "/work/connector" + type dispatchHarness struct { ledger *Ledger fake *fakeDriver d *Dispatcher - routes map[int64]admission.Route - mu sync.Mutex + served map[int64]admission.Project + // servedErr stands in for a connect.json that cannot be read. + servedErr error + mu sync.Mutex } func newDispatchHarness(t *testing.T, fake *fakeDriver, tweak func(*DispatcherOptions)) *dispatchHarness { t.Helper() - h := &dispatchHarness{ledger: newTestLedger(t), fake: fake, routes: map[int64]admission.Route{adapterBucketID: {Path: testRoute}}} + h := &dispatchHarness{ledger: newTestLedger(t), fake: fake, served: map[int64]admission.Project{adapterBucketID: {}}} // Session directories hold a unix socket, whose path the kernel keeps // short; a test's own temporary directory can be too long for one. private, err := os.MkdirTemp("/tmp", "bcc-test-") @@ -176,15 +182,19 @@ func newDispatchHarness(t *testing.T, fake *fakeDriver, tweak func(*DispatcherOp opts := DispatcherOptions{ Ledger: h.ledger, Driver: fake, - Routes: func() map[int64]admission.Route { + Served: func() (map[int64]admission.Project, error) { h.mu.Lock() defer h.mu.Unlock() - out := map[int64]admission.Route{} - for k, v := range h.routes { + if h.servedErr != nil { + return nil, h.servedErr + } + out := map[int64]admission.Project{} + for k, v := range h.served { out[k] = v } - return out + return out, nil }, + WorkDir: testWorkDir, Concurrency: 2, Deadline: time.Hour, MCP: WorkerMCP{Command: "/usr/local/bin/basecamp", Profile: "agent", StateDir: "/state/2914079-52007412"}, @@ -337,8 +347,7 @@ func TestNothingCrossesToTheWorkerThatItDoesNotNeed(t *testing.T) { } _, hostToken := cfg.MCPServers[0].Env["BASECAMP_TOKEN"] assert.False(t, hostToken) - assert.Equal(t, testRoute, cfg.Cwd) - assert.Equal(t, testRoute, cfg.Policy.Rules().WorkDir) + assert.Equal(t, testWorkDir, cfg.Cwd, "the worker runs where the connector was started") serverEnv := make([]string, 0, len(cfg.MCPServers[0].Env)) for k, v := range cfg.MCPServers[0].Env { serverEnv = append(serverEnv, k+"="+v) @@ -500,10 +509,10 @@ func TestAFollowUpIsExposedBeforeItsPromptInTheSameSession(t *testing.T) { } // Dispatcher invariant 2. -func TestARouteNoLongerApprovedIsNotDispatched(t *testing.T) { +func TestAProjectNoLongerServedIsNotDispatched(t *testing.T) { fake := newFakeDriver() h := newDispatchHarness(t, fake, nil) - h.routes = map[int64]admission.Route{adapterBucketID: {Path: "/another/checkout"}} + h.served = map[int64]admission.Project{} admitOn(t, h.ledger, 1, "recording:1") h.run(t) time.Sleep(150 * time.Millisecond) @@ -524,11 +533,9 @@ func TestConcurrencyIsABound(t *testing.T) { } h := newDispatchHarness(t, fake, nil) for i, id := range []int64{1, 2, 3} { - route := "/work/r" + string(rune('a'+i)) - h.routes[adapterBucketID+int64(i)] = admission.Route{Path: route} + h.served[adapterBucketID+int64(i)] = admission.Project{} seenRecord(t, h.ledger, id) v := admittedVerdict(id, 0, "recording:"+string(rune('a'+i))) - v.Route = route _, err := h.ledger.ledgerCommitWithBucket(v, adapterBucketID+int64(i)) require.NoError(t, err) } @@ -544,7 +551,7 @@ func TestConcurrencyIsABound(t *testing.T) { } // ledgerCommitWithBucket admits v and moves its record to another bucket, so -// tests can have several routed projects. +// tests can have several served projects. func (l *Ledger) ledgerCommitWithBucket(v admission.Verdict, bucket int64) (admission.State, error) { state, err := l.Admission().Commit(context.Background(), v) if err != nil { @@ -622,61 +629,37 @@ func nextSession(t *testing.T, fake *fakeDriver) *fakeSession { } } -// admitRouted admits a record on its own conversation in bucket, routed to -// route. -func admitRouted(t *testing.T, ledger *Ledger, id, bucket int64, key, route string) { +// admitIn admits a record on its own conversation in bucket. +func admitIn(t *testing.T, ledger *Ledger, id, bucket int64, key string) { t.Helper() seenRecord(t, ledger, id) v := admittedVerdict(id, 0, key) - v.Route = route _, err := ledger.ledgerCommitWithBucket(v, bucket) require.NoError(t, err) } // Review r1, blocking: records the dispatcher cannot start never fill the -// window ahead of one it can. +// window ahead of one it can. A project connect.json does not serve is the +// case that remains: nothing else is filtered out of the query now that no +// task holds a directory. func TestRecordsTheDispatcherCannotStartDoNotStarveOthers(t *testing.T) { - t.Run("a route no longer approved", func(t *testing.T) { - fake := newFakeDriver() - h := newDispatchHarness(t, fake, nil) - for i := int64(1); i <= 12; i++ { - admitRouted(t, h.ledger, i, 777, "recording:u"+string(rune('a'+i)), "/unrouted") - } - admitRouted(t, h.ledger, 50, adapterBucketID, "recording:ok", testRoute) - h.run(t) - s := nextSession(t, fake) - assert.Equal(t, int64(50), s.cfg.Scope.EventIDs[0]) - }) - t.Run("a backlog on a busy route", func(t *testing.T) { - fake := newFakeDriver() - hold := make(chan struct{}) - fake.turn = func(s *fakeSession, _ int, _ string) (driver.PromptResult, error) { - select { - case <-hold: - case <-s.canceled: - return driver.PromptResult{Stop: driver.TurnCanceled}, nil - } - return driver.PromptResult{Stop: driver.TurnEndTurn}, nil - } - h := newDispatchHarness(t, fake, nil) - h.routes[888] = admission.Route{Path: "/work/other"} - for i := int64(1); i <= 12; i++ { - admitRouted(t, h.ledger, i, adapterBucketID, "recording:b"+string(rune('a'+i)), testRoute) - } - admitRouted(t, h.ledger, 50, 888, "recording:other", "/work/other") - h.run(t) - first, second := nextSession(t, fake), nextSession(t, fake) - assert.ElementsMatch(t, []string{testRoute, "/work/other"}, []string{first.cfg.Cwd, second.cfg.Cwd}) - close(hold) - }) + fake := newFakeDriver() + h := newDispatchHarness(t, fake, nil) + for i := int64(1); i <= 12; i++ { + admitIn(t, h.ledger, i, 777, "recording:u"+string(rune('a'+i))) + } + admitIn(t, h.ledger, 50, adapterBucketID, "recording:ok") + h.run(t) + s := nextSession(t, fake) + assert.Equal(t, int64(50), s.cfg.Scope.EventIDs[0]) } func TestTheProjectScopeNarrowsDispatch(t *testing.T) { fake := newFakeDriver() h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Buckets = []int64{888} }) - h.routes[888] = admission.Route{Path: "/work/other"} - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:1", testRoute) - admitRouted(t, h.ledger, 2, 888, "recording:2", "/work/other") + h.served[888] = admission.Project{} + admitIn(t, h.ledger, 1, adapterBucketID, "recording:1") + admitIn(t, h.ledger, 2, 888, "recording:2") h.run(t) s := nextSession(t, fake) assert.Equal(t, int64(2), s.cfg.Scope.EventIDs[0]) @@ -731,10 +714,14 @@ func TestExitsTheDispatcherCausedAreNotFailures(t *testing.T) { }) } -// Copilot and review r1, 5: an unverifiable worker is not settled around. +// Copilot and review r1, 5: an unverifiable worker is not settled around. Its +// attempt stays live and keeps the slot it took; with the concurrency at one, +// that is the whole window, so nothing else runs. It holds no directory — +// there is none to hold — so at a higher concurrency another conversation +// would start beside it. func TestAWorkerThatCannotBeVerifiedKeepsItsAttemptLive(t *testing.T) { fake := newFakeDriver() - h := newDispatchHarness(t, fake, nil) + h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Concurrency = 1 }) admitOn(t, h.ledger, 1, "recording:1") l := launch(t, h.ledger, 1) require.NoError(t, h.ledger.MarkRunning(context.Background(), l.AttemptID, AttemptProcess{PID: 4242, PGID: 4242, StartedAt: time.Now(), SessionID: "s"})) @@ -749,7 +736,7 @@ func TestAWorkerThatCannotBeVerifiedKeepsItsAttemptLive(t *testing.T) { time.Sleep(150 * time.Millisecond) fake.mu.Lock() defer fake.mu.Unlock() - assert.Empty(t, fake.sessions, "its directory stays held") + assert.Empty(t, fake.sessions, "its slot stays taken") } // Review r1, 7. @@ -772,8 +759,9 @@ func TestASettlementThatFailsIsRetried(t *testing.T) { assert.Equal(t, "finished", h.attemptsEnded(t, 1)[0].StopReason) } -// Copilot r2: a route revoked while a task runs stops follow-ups joining it. -func TestAFollowUpDoesNotJoinATaskWhoseRouteWasRevoked(t *testing.T) { +// Copilot r2: a project unserved while a task runs stops follow-ups joining +// it. +func TestAFollowUpDoesNotJoinATaskWhoseProjectWasUnserved(t *testing.T) { fake := newFakeDriver() release := make(chan struct{}) fake.turn = func(*fakeSession, int, string) (driver.PromptResult, error) { @@ -786,18 +774,18 @@ func TestAFollowUpDoesNotJoinATaskWhoseRouteWasRevoked(t *testing.T) { s := nextSession(t, fake) h.mu.Lock() - h.routes = map[int64]admission.Route{} + h.served = map[int64]admission.Project{} h.mu.Unlock() admitOn(t, h.ledger, 2, "recording:1") time.Sleep(150 * time.Millisecond) - assert.Equal(t, StateQueued, getRecord(t, h.ledger, 2).State, "not handed to a worker in a directory no longer approved") + assert.Equal(t, StateQueued, getRecord(t, h.ledger, 2).State, "not handed to a worker for a project no longer served") close(release) h.attemptsEnded(t, 1) assert.Len(t, s.promptList(), 1) } // Copilot r2: a crash mid-launch leaves a worker nobody can name. -func TestAnAttemptLeftMidLaunchKeepsItsDirectoryHeld(t *testing.T) { +func TestAnAttemptLeftMidLaunchKeepsItsSlot(t *testing.T) { fake := newFakeDriver() h := newDispatchHarness(t, fake, nil) admitOn(t, h.ledger, 1, "recording:1") @@ -859,16 +847,16 @@ func TestAnAttemptLeftLiveHoldsAWorkerSlot(t *testing.T) { return driver.PromptResult{Stop: driver.TurnEndTurn}, nil } h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Concurrency = 2 }) - // One attempt whose worker cannot be identified, on its own route. - h.routes[900] = admission.Route{Path: "/work/held"} - admitRouted(t, h.ledger, 1, 900, "recording:held", "/work/held") - _, err := h.ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Route: "/work/held", Driver: "fake"}) + // One attempt whose worker cannot be identified. + h.served[900] = admission.Project{} + admitIn(t, h.ledger, 1, 900, "recording:held") + _, err := h.ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Served: []int64{900, adapterBucketID}, Driver: "fake"}) require.NoError(t, err) - // Two more conversations, each with a route of its own. - h.routes[901] = admission.Route{Path: "/work/a"} - h.routes[902] = admission.Route{Path: "/work/b"} - admitRouted(t, h.ledger, 2, 901, "recording:a", "/work/a") - admitRouted(t, h.ledger, 3, 902, "recording:b", "/work/b") + // Two more conversations. + h.served[901] = admission.Project{} + h.served[902] = admission.Project{} + admitIn(t, h.ledger, 2, 901, "recording:a") + admitIn(t, h.ledger, 3, 902, "recording:b") require.NoError(t, h.d.Recover(context.Background())) h.run(t) @@ -882,9 +870,8 @@ func TestAnAttemptLeftLiveHoldsAWorkerSlot(t *testing.T) { } // The one-owner rule (see internal/connector/driver/worker.go): a task whose -// process tree is still alive never has its directory released or its record -// settled. -func TestATaskWithASurvivingGrandchildNeverReleasesItsDirectory(t *testing.T) { +// process tree is still alive never has its record settled. +func TestATaskWithASurvivingGrandchildIsNeverSettled(t *testing.T) { work := t.TempDir() worker, grandchild := drivertest.StartTree(t, work) <-worker.Done() // the leader is gone; its grandchild is not @@ -904,8 +891,7 @@ func TestATaskWithASurvivingGrandchildNeverReleasesItsDirectory(t *testing.T) { } return nil } - h.routes[adapterBucketID] = admission.Route{Path: work} - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:1", work) + admitIn(t, h.ledger, 1, adapterBucketID, "recording:1") h.run(t) require.Eventually(t, func() bool { @@ -962,9 +948,8 @@ func TestRecoveryReleasesNothingWhileTheRecordedGroupSurvives(t *testing.T) { o.Lines = ndjson.NewWriter(lines) o.CancelGrace = 100 * time.Millisecond }) - h.routes[adapterBucketID] = admission.Route{Path: work} - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:1", work) - l, err := h.ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Route: work, Driver: "fake"}) + admitIn(t, h.ledger, 1, adapterBucketID, "recording:1") + l, err := h.ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Served: []int64{900, adapterBucketID}, Driver: "fake"}) require.NoError(t, err) require.NoError(t, h.ledger.MarkRunning(context.Background(), l.AttemptID, AttemptProcess{ PID: worker.PID, PGID: worker.PGID, StartedAt: worker.StartedAt, SessionID: "s", @@ -1031,8 +1016,7 @@ func TestAStartThatFailedAfterLaunchingReleasesNothingWhileItsGroupLives(t *test } return nil } - h.routes[adapterBucketID] = admission.Route{Path: work} - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:1", work) + admitIn(t, h.ledger, 1, adapterBucketID, "recording:1") h.run(t) require.Eventually(t, func() bool { @@ -1379,13 +1363,12 @@ func TestAHeldAttemptTakesASlotWithinTheSamePass(t *testing.T) { } h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Concurrency = 2 }) h.d.confirmGroupGone = func(driver.Process, time.Duration) error { return driver.ErrGroupOutlivedLeader } - // Three records on three directories, so nothing but the bound stops them. + // Three records on three conversations, so nothing but the bound stops + // them. for i, id := range []int64{1, 2, 3} { - route := "/work/held" + string(rune('a'+i)) - h.routes[adapterBucketID+int64(i)] = admission.Route{Path: route} + h.served[adapterBucketID+int64(i)] = admission.Project{} seenRecord(t, h.ledger, id) v := admittedVerdict(id, 0, "recording:held"+string(rune('a'+i))) - v.Route = route _, err := h.ledger.ledgerCommitWithBucket(v, adapterBucketID+int64(i)) require.NoError(t, err) } @@ -1497,8 +1480,8 @@ func TestARefusedHandoffIsAlwaysSaidOutLoud(t *testing.T) { // Copilot on #738: a delivered token whose holder could not be identified // used to be the same zero taker as no delivery at all, so the release point -// settled the attempt and released its directory around a process that may -// still have held the task's credential. It is held instead — here, and +// settled the attempt around a process that may still have held the task's +// credential. It is held instead — here, and // after a restart, because the ledger carries the state too. func TestAnAttemptWhoseTokenHolderIsUnaccountedForIsHeld(t *testing.T) { h := newDispatchHarness(t, newFakeDriver(), nil) @@ -1603,3 +1586,54 @@ func TestAShutdownDoesNotWaitOutTheAdoptionBudget(t *testing.T) { t.Fatal("a shutdown waited on the adoption budget") } } + +// Copilot on #765: an unreadable connect.json is not "no project is served" +// for the dispatcher either. The holding reply stopped making that claim on +// a card; the stranded report would have gone on making it in the log, +// counting every startable record and telling the operator to serve or +// discard a project that may be served already. +func TestAnUnreadableConfigStrandsNothingAndSaysWhy(t *testing.T) { + var logged safeBuffer + fake := newFakeDriver() + h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { + o.Logger = slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelWarn})) + }) + admitIn(t, h.ledger, 1, adapterBucketID, "recording:1") + + h.mu.Lock() + h.servedErr = errors.New("connect.json cannot be read") + h.mu.Unlock() + + h.run(t) + time.Sleep(200 * time.Millisecond) + + fake.mu.Lock() + sessions := len(fake.sessions) + fake.mu.Unlock() + assert.Zero(t, sessions, "nothing is authorized while the answer cannot be read") + + out := logged.String() + assert.Contains(t, out, "could not be read", "the operator is told what actually happened") + assert.Contains(t, out, "dispatch is paused", "and what it means for them") + assert.NotContains(t, out, "no longer serves", + "and not told their project was unserved, which nothing established") + assert.NotContains(t, out, "work is waiting", + "nor told work is waiting, which the skipped count is the only thing that could have established") +} + +// And it says nothing about waiting work on an empty ledger either: the +// count that would establish it is the one being skipped. +func TestAnUnreadableConfigClaimsNoWaitingWorkOnAnEmptyLedger(t *testing.T) { + var logged safeBuffer + fake := newFakeDriver() + h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { + o.Logger = slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelWarn})) + }) + h.mu.Lock() + h.servedErr = errors.New("connect.json cannot be read") + h.mu.Unlock() + + h.run(t) + time.Sleep(200 * time.Millisecond) + assert.NotContains(t, logged.String(), "work is waiting", "there is none, and nothing counted") +} diff --git a/internal/connector/driver/acp/acp.go b/internal/connector/driver/acp/acp.go index 7340b8e9e..e35524ab3 100644 --- a/internal/connector/driver/acp/acp.go +++ b/internal/connector/driver/acp/acp.go @@ -40,11 +40,10 @@ // when loadSession is true, session/resume when sessionCapabilities.resume // is present, otherwise an error. Its history replay is not progress. // 6. A configuration this driver cannot run — an adapter with no asking mode -// for the policy's, a policy for another directory, an MCP server without -// an absolute command, a Codex config that declares MCP servers or that -// cannot be read — is -// ErrUnusable beside ErrNotStarted: nothing started, and a retry would -// fail the same way. +// for the policy's, a session with no absolute working directory to start +// in, an MCP server without an absolute command, a Codex config that +// declares MCP servers or that cannot be read — is ErrUnusable beside +// ErrNotStarted: nothing started, and a retry would fail the same way. // 7. The adapter is the pinned one: initialize must report protocol version // 1 and the Adapter's package and version, or the session is ended. // 8. Nothing the agent volunteers is kept: _auth/status_update (which @@ -204,9 +203,6 @@ func (d *Driver) open(ctx context.Context, cfg driver.SessionConfig, loadID stri if !ok { return nil, fmt.Errorf("%w: %w: %w: %s has no asking mode for policy mode %q", driver.ErrNotStarted, driver.ErrUnusable, driver.ErrUnsafeMode, d.opts.Adapter.Name, rules.Mode) } - if filepath.Clean(rules.WorkDir) != filepath.Clean(cfg.Cwd) { - return nil, fmt.Errorf("%w: %w: the policy's working directory is not the session's", driver.ErrNotStarted, driver.ErrUnusable) - } servers, err := wireServers(cfg.MCPServers) if err != nil { return nil, fmt.Errorf("%w: %w: %w", driver.ErrNotStarted, driver.ErrUnusable, err) diff --git a/internal/connector/driver/acp/acp_test.go b/internal/connector/driver/acp/acp_test.go index 1ace24fda..267dbc1f4 100644 --- a/internal/connector/driver/acp/acp_test.go +++ b/internal/connector/driver/acp/acp_test.go @@ -53,7 +53,7 @@ var testAdapter = Adapter{ Version: testVersion, Env: []string{"FAKE_AGENT_KEY"}, SetEnv: map[string]string{"FAKE_AGENT_SWITCH": "on"}, - Modes: map[driver.PermissionMode]string{driver.ModeEditsInWorkDir: "ask"}, + Modes: map[driver.PermissionMode]string{driver.ModeEdits: "ask"}, SessionMeta: map[string]any{ "vendor": map[string]any{"settingSources": []string{}}, }, @@ -70,7 +70,7 @@ type recordingPolicy struct { } func (p *recordingPolicy) Rules() driver.PermissionRules { - return driver.PermissionRules{Mode: driver.ModeEditsInWorkDir, WorkDir: p.workDir} + return driver.PermissionRules{Mode: driver.ModeEdits} } func (p *recordingPolicy) Decide(_ context.Context, req driver.PermissionRequest) driver.PermissionDecision { @@ -879,7 +879,7 @@ func TestThePinnedAdapters(t *testing.T) { got, ok := AdapterNamed(a.Name) require.True(t, ok) assert.Equal(t, a.Package, got.Package) - assert.NotEmpty(t, a.Modes[driver.ModeEditsInWorkDir], a.Name) + assert.NotEmpty(t, a.Modes[driver.ModeEdits], a.Name) for _, name := range a.Env { assert.NotContains(t, []string{"CLAUDE_CODE_EXECUTABLE", "CODEX_PATH", "CLAUDE_CODE_MESSAGING_TOKEN", "BASECAMP_TOKEN"}, name, "%s may not take a variable that swaps its pinned agent or carries the host's token", a.Name) diff --git a/internal/connector/driver/acp/adapters.go b/internal/connector/driver/acp/adapters.go index 76e34d7fa..a4bf1eea0 100644 --- a/internal/connector/driver/acp/adapters.go +++ b/internal/connector/driver/acp/adapters.go @@ -86,7 +86,7 @@ var ClaudeAgentACP = Adapter{ Version: "0.78.0", Env: append([]string{}, claude.Env...), Modes: map[driver.PermissionMode]string{ - driver.ModeEditsInWorkDir: "default", + driver.ModeEdits: "default", }, SessionMeta: map[string]any{ "claudeCode": map[string]any{ @@ -147,7 +147,7 @@ var CodexACP = Adapter{ // the agent what it can call), so it is named here and nothing else is. Readback: Readback{Command: "/mcp", Parse: codexMCPReport, BuiltIn: []string{"codex_apps"}}, Modes: map[driver.PermissionMode]string{ - driver.ModeEditsInWorkDir: "read-only", + driver.ModeEdits: "read-only", }, LoadSession: true, } diff --git a/internal/connector/driver/acp/compat_test.go b/internal/connector/driver/acp/compat_test.go index 7632bb9e2..e972ca8d0 100644 --- a/internal/connector/driver/acp/compat_test.go +++ b/internal/connector/driver/acp/compat_test.go @@ -173,7 +173,7 @@ type compatPolicy struct { func (p *compatPolicy) Rules() driver.PermissionRules { return driver.PermissionRules{ - Mode: driver.ModeEditsInWorkDir, WorkDir: p.workDir, + Mode: driver.ModeEdits, AllowKinds: []driver.ToolKind{driver.ToolRead, driver.ToolSearch, driver.ToolThink}, AllowMCPServers: []string{compatServer}, } diff --git a/internal/connector/driver/acp/permission.go b/internal/connector/driver/acp/permission.go index 8b3fe336e..3bd316590 100644 --- a/internal/connector/driver/acp/permission.go +++ b/internal/connector/driver/acp/permission.go @@ -129,10 +129,11 @@ func (s *session) onRequest(id json.RawMessage, method string, params json.RawMe Locations: slices.Clone(info.locations), } if info.unplaceable || call.Unplaceable { - // The policy allows such a call only when every path it names is - // inside the working directory, and this is a call whose paths this - // driver could not carry whole. It is refused without being asked, - // rather than judged on the paths that fit. + // A call whose paths this driver could not carry whole is one it + // cannot describe to the policy, so it is refused without being + // asked rather than judged on the paths that fit. Fail closed: the + // policy is a seam a sandbox launcher will take over, and a call it + // was never shown must not pass by default. s.refuse(id, req, t) return } diff --git a/internal/connector/driver/acp/session.go b/internal/connector/driver/acp/session.go index 6549746e7..3a9743929 100644 --- a/internal/connector/driver/acp/session.go +++ b/internal/connector/driver/acp/session.go @@ -934,10 +934,10 @@ func decodeUpdate(raw json.RawMessage) (sessionUpdate, bool) { var locations []json.RawMessage if json.Unmarshal(fields["locations"], &locations) == nil { if len(locations) > maxLocations { - // More paths than this driver carries. The policy allows a call - // only when every path it names is inside the working directory, - // so judging it on the ones that fit would allow a call by - // leaving out the path that refuses it. + // More paths than this driver carries, so the policy could not + // be shown the call whole. Marked unplaceable and refused + // without being asked (permission.go), rather than judged on the + // paths that fit. u.Unplaceable = true locations = locations[:maxLocations] } @@ -950,9 +950,9 @@ func decodeUpdate(raw json.RawMessage) (sessionUpdate, bool) { } if len(loc.Path) > maxLocationPath { // A pathname longer than the driver carries is not a path - // this call can be placed by either: what is cut off can be - // the part that leaves the working directory, and a tool that - // normalizes before it opens would still reach it. + // this call can be placed by either: what is cut off is part + // of where the call would land, so the policy would be shown + // a path that is not the one the tool opens. u.Unplaceable = true loc.Path = loc.Path[:maxLocationPath] } diff --git a/internal/connector/driver/claude/claude.go b/internal/connector/driver/claude/claude.go index d5264081d..1d2eaccef 100644 --- a/internal/connector/driver/claude/claude.go +++ b/internal/connector/driver/claude/claude.go @@ -118,11 +118,11 @@ func (d *Driver) redactor(cfg driver.SessionConfig) *driver.Redactor { // modeIDs maps the connector's permission modes to Claude Code's. var modeIDs = map[driver.PermissionMode]string{ - driver.ModeEditsInWorkDir: "acceptEdits", + driver.ModeEdits: "acceptEdits", } // kindTools are Claude Code's built-in tools for each kind the policy can -// allow. Edits are acceptEdits's, confined to the working directory. +// allow. Edits are acceptEdits's. var kindTools = map[driver.ToolKind][]string{ driver.ToolRead: {"Read"}, driver.ToolSearch: {"Glob", "Grep"}, @@ -138,9 +138,6 @@ func Args(cfg driver.SessionConfig, sessionID string, resume bool, mcpConfigPath if !ok { return nil, fmt.Errorf("claude: no Claude Code mode for policy mode %q", rules.Mode) } - if filepath.Clean(rules.WorkDir) != filepath.Clean(cfg.Cwd) { - return nil, fmt.Errorf("claude: the policy's working directory %q is not the session's %q", rules.WorkDir, cfg.Cwd) - } tools := slices.Clone(kindTools[driver.ToolEdit]) var allowed []string for _, kind := range rules.AllowKinds { @@ -148,9 +145,8 @@ func Args(cfg driver.SessionConfig, sessionID string, resume bool, mcpConfigPath if !ok { return nil, fmt.Errorf("claude: no Claude Code tools for kind %q", kind) } - // The tools exist in the session but get no allow rule: an allow - // rule for Read is a read anywhere on disk, where the policy allows - // reads in the working directory, which the mode already grants. + // The tools exist in the session but get no allow rule: the mode + // already grants them, and an allow rule would only widen it. tools = append(tools, names...) } for _, server := range rules.AllowMCPServers { diff --git a/internal/connector/driver/claude/claude_test.go b/internal/connector/driver/claude/claude_test.go index 11d65ba0b..1aa4e1e7a 100644 --- a/internal/connector/driver/claude/claude_test.go +++ b/internal/connector/driver/claude/claude_test.go @@ -280,7 +280,7 @@ func newFixture(t *testing.T, scenario string) fixture { Name: "basecamp", Command: "/usr/local/bin/basecamp", Args: []string{"mcp", "--profile", "agent"}, Env: map[string]string{"BASECAMP_CONNECT_TASK_TOKEN": "test-token-not-real"}, }}, - Policy: policy{workDir: work}, + Policy: policy{}, Scope: driver.Scope{WorkDir: work}, PrivateDir: private, }, @@ -305,7 +305,7 @@ func (f fixture) report_() (fakeReport, error) { return r, json.Unmarshal(data, &r) } -type policy struct{ workDir string } +type policy struct{} func (p policy) Decide(context.Context, driver.PermissionRequest) driver.PermissionDecision { return driver.PermissionDecision{} @@ -313,7 +313,7 @@ func (p policy) Decide(context.Context, driver.PermissionRequest) driver.Permiss func (p policy) Rules() driver.PermissionRules { return driver.PermissionRules{ - Mode: driver.ModeEditsInWorkDir, WorkDir: p.workDir, + Mode: driver.ModeEdits, AllowKinds: []driver.ToolKind{driver.ToolRead, driver.ToolSearch}, AllowMCPServers: []string{"basecamp"}, } } @@ -342,12 +342,8 @@ func TestArgsFreezeThePolicyAndCarryNoSecret(t *testing.T) { assert.NotContains(t, tools, "Bash") assert.NotContains(t, tools, "WebFetch") assert.Equal(t, "mcp__basecamp", argAfter(args, "--allowed-tools"), "no read tool is an allow rule: that would allow reads anywhere") - assert.Contains(t, tools, "Read", "the tool exists; the mode confines it to the working directory") + assert.Contains(t, tools, "Read", "the tool exists, with no allow rule of its own") assert.NotContains(t, strings.Join(args, " "), "test-token-not-real") - - f.cfg.Cwd = "/elsewhere" - _, err = Args(f.cfg, "11111111-2222-4333-8444-555555555555", false, "/private/mcp.json", "") - assert.Error(t, err, "a policy for another directory is not this session's") } func TestASessionRunsAVerifiedTurnAndRecordsRefusals(t *testing.T) { diff --git a/internal/connector/driver/codex/codex.go b/internal/connector/driver/codex/codex.go index fab018fe0..3a2d44f4d 100644 --- a/internal/connector/driver/codex/codex.go +++ b/internal/connector/driver/codex/codex.go @@ -208,12 +208,9 @@ func Args(cfg driver.SessionConfig, resumeID, model string) ([]string, error) { return nil, errors.New("codex: a session needs a policy") } rules := cfg.Policy.Rules() - if rules.Mode != driver.ModeEditsInWorkDir { + if rules.Mode != driver.ModeEdits { return nil, fmt.Errorf("%w: codex: no Codex sandbox for policy mode %q", driver.ErrUnusable, rules.Mode) } - if filepath.Clean(rules.WorkDir) != filepath.Clean(cfg.Cwd) { - return nil, fmt.Errorf("%w: codex: the policy's working directory %q is not the session's %q", driver.ErrUnusable, rules.WorkDir, cfg.Cwd) - } for _, kind := range rules.AllowKinds { if !slices.Contains(allowedKinds, kind) { return nil, fmt.Errorf("%w: codex: no Codex policy allows kind %q and nothing else", driver.ErrUnusable, kind) @@ -233,8 +230,12 @@ func Args(cfg driver.SessionConfig, resumeID, model string) ([]string, error) { // and its execpolicy rules are not this session's. "--ignore-user-config", "--ignore-rules", - // connect.json approved the directory; Codex's own trust prompt has - // nobody to answer it. + // Codex refuses to run outside a git repository without this, and + // asks the person to trust the directory instead. Nobody is there + // to answer that: the connector runs where it was started, which + // need not be a repository at all, and connect.json has nothing to + // say about a directory. What bounds the writes is the sandbox two + // lines below, not this flag and not any check the connector makes. "--skip-git-repo-check", "-c", "approval_policy="+tomlString(approvalNever), "-c", "sandbox_mode="+tomlString(sandboxWorkdir), diff --git a/internal/connector/driver/codex/codex_test.go b/internal/connector/driver/codex/codex_test.go index 7a3b2c8d0..6d8eac3fa 100644 --- a/internal/connector/driver/codex/codex_test.go +++ b/internal/connector/driver/codex/codex_test.go @@ -108,7 +108,6 @@ func (h *harness) scenario(sc scenario) { } type testPolicy struct { - workDir string kinds []driver.ToolKind servers []string mode driver.PermissionMode @@ -121,9 +120,9 @@ func (p testPolicy) Decide(context.Context, driver.PermissionRequest) driver.Per func (p testPolicy) Rules() driver.PermissionRules { mode := p.mode if mode == "" { - mode = driver.ModeEditsInWorkDir + mode = driver.ModeEdits } - return driver.PermissionRules{Mode: mode, WorkDir: p.workDir, AllowKinds: p.kinds, AllowMCPServers: p.servers} + return driver.PermissionRules{Mode: mode, AllowKinds: p.kinds, AllowMCPServers: p.servers} } func (h *harness) config() driver.SessionConfig { @@ -136,7 +135,7 @@ func (h *harness) config() driver.SessionConfig { Args: []string{"-c", `env > "$MCP_ENV_OUT"`}, Env: map[string]string{"MCP_ENV_OUT": h.mcpOut, "SERVER_ONLY_NOT_SECRET": serverOnly, "PATH": os.Getenv("PATH")}, }}, - Policy: connector.DefaultPolicy(h.workDir), + Policy: connector.DefaultPolicy(), Scope: driver.Scope{WorkDir: h.workDir}, PrivateDir: h.private, } @@ -171,7 +170,7 @@ func turnCompleted() string { func TestArgsHoldThePolicy(t *testing.T) { cfg := driver.SessionConfig{ Cwd: "/work/app", - Policy: connector.DefaultPolicy("/work/app"), + Policy: connector.DefaultPolicy(), MCPServers: []driver.MCPServer{ {Name: "basecamp", Command: "/bin/basecamp", Args: []string{"connect", "worker-mcp", "--socket", "/run/token.sock"}, Env: map[string]string{"HOME": "/home/op", "BASECAMP_NO_KEYRING": `a"quoted\value`}}, {Name: "other", Command: "/bin/other"}, @@ -217,13 +216,12 @@ func TestArgsHoldThePolicy(t *testing.T) { func TestArgsRefuseAPolicyCodexCannotHold(t *testing.T) { server := []driver.MCPServer{{Name: "basecamp", Command: "/bin/basecamp"}} for name, cfg := range map[string]driver.SessionConfig{ - "another mode": {Cwd: "/w", Policy: testPolicy{workDir: "/w", mode: "anything"}, MCPServers: server}, - "another workdir": {Cwd: "/w", Policy: testPolicy{workDir: "/elsewhere"}, MCPServers: server}, - "execute allowed": {Cwd: "/w", Policy: testPolicy{workDir: "/w", kinds: []driver.ToolKind{driver.ToolExecute}}, MCPServers: server}, - "fetch allowed": {Cwd: "/w", Policy: testPolicy{workDir: "/w", kinds: []driver.ToolKind{driver.ToolFetch}}, MCPServers: server}, - "unkeyable server": {Cwd: "/w", Policy: testPolicy{workDir: "/w"}, MCPServers: []driver.MCPServer{{Name: "a.b", Command: "/bin/x"}}}, - "no command": {Cwd: "/w", Policy: testPolicy{workDir: "/w"}, MCPServers: []driver.MCPServer{{Name: "other"}}}, - "unkeyable env name": {Cwd: "/w", Policy: testPolicy{workDir: "/w"}, MCPServers: []driver.MCPServer{{Name: "other", Command: "/bin/x", Env: map[string]string{"A=B": "x"}}}}, + "another mode": {Cwd: "/w", Policy: testPolicy{mode: "anything"}, MCPServers: server}, + "execute allowed": {Cwd: "/w", Policy: testPolicy{kinds: []driver.ToolKind{driver.ToolExecute}}, MCPServers: server}, + "fetch allowed": {Cwd: "/w", Policy: testPolicy{kinds: []driver.ToolKind{driver.ToolFetch}}, MCPServers: server}, + "unkeyable server": {Cwd: "/w", Policy: testPolicy{}, MCPServers: []driver.MCPServer{{Name: "a.b", Command: "/bin/x"}}}, + "no command": {Cwd: "/w", Policy: testPolicy{}, MCPServers: []driver.MCPServer{{Name: "other"}}}, + "unkeyable env name": {Cwd: "/w", Policy: testPolicy{}, MCPServers: []driver.MCPServer{{Name: "other", Command: "/bin/x", Env: map[string]string{"A=B": "x"}}}}, } { _, err := Args(cfg, "", "") assert.ErrorIs(t, err, driver.ErrUnusable, name) diff --git a/internal/connector/driver/driver.go b/internal/connector/driver/driver.go index 0b9aa7681..437e52126 100644 --- a/internal/connector/driver/driver.go +++ b/internal/connector/driver/driver.go @@ -191,7 +191,8 @@ type Session interface { // dispatcher builds it from the task's record; the driver adds nothing of its // own beyond its binary and its flags. type SessionConfig struct { - // Cwd is the approved working directory, absolute. + // Cwd is the directory the session runs in, absolute: the connector's + // own, the one it was started in. Cwd string // Env is the worker process's whole environment, as KEY=VALUE. Nothing // else is inherited (invariant 1). BuildEnv makes one from an allowlist. @@ -442,13 +443,18 @@ type PermissionDecision struct { } // PermissionRules is a policy pre-decided. +// +// It names no directory. The connector's policy bounded edits to the working +// directory while a project was routed to one; with the routes gone the only +// directory left is wherever the operator started the connector, and a bound +// that moves with that is a guarantee in name and an accident in behavior. +// What confines a worker to a directory now is the agent's own sandbox +// (Codex's workspace-write) or the sandbox launcher being built separately — +// not this. type PermissionRules struct { // Mode is the asking mode the agent must run in and confirm. Mode PermissionMode - // WorkDir is where edits are allowed; everything outside it is refused. - WorkDir string - // AllowKinds are the tool kinds allowed without asking, besides edits - // inside WorkDir. + // AllowKinds are the tool kinds allowed without asking, besides edits. AllowKinds []ToolKind // AllowMCPServers are the MCP servers whose every tool is allowed. AllowMCPServers []string @@ -460,9 +466,10 @@ type PermissionRules struct { type PermissionMode string const ( - // ModeEditsInWorkDir allows edits inside the working directory, and - // refuses, without asking anyone, whatever the rules do not allow. - ModeEditsInWorkDir PermissionMode = "edits_in_workdir" + // ModeEdits allows edits, and refuses, without asking anyone, whatever + // the rules do not allow. It bounds where an edit may land no further + // than the agent's own sandbox does. + ModeEdits PermissionMode = "edits" ) // Launcher wraps the worker command: the seam where a sandbox launcher @@ -485,7 +492,9 @@ type Scope struct { // has been handed to the worker when the session starts; the others are // exposed as they are prompted. EventIDs []int64 - // WorkDir is the approved working directory the record carries. + // WorkDir is the directory the worker runs in: the connector's own, the + // one it was started in. It is where the process starts, not a bound on + // where it may write. WorkDir string // SocketDir holds the task token's unix socket, which the worker's MCP // server dials. A launcher that confines a worker must let it reach this @@ -533,7 +542,7 @@ type DirectLauncher struct{} // Launch implements Launcher. func (DirectLauncher) Launch(_ context.Context, req LaunchRequest) (Launched, error) { if req.Scope.WorkDir == "" { - return Launched{}, errors.New("driver: a launch needs the working directory the record carries") + return Launched{}, errors.New("driver: a launch needs a working directory to start the process in") } cmd := req.Command cmd.Dir = req.Scope.WorkDir @@ -545,7 +554,7 @@ func (DirectLauncher) Receipts(context.Context, string) ([]Receipt, error) { ret // StartError is a start that failed after it launched a process. The // driver has asked the process's group to end; the connector owns confirming -// it gone before it settles the attempt or releases its directory. +// it gone before it settles the attempt. type StartError struct { Process Process Err error diff --git a/internal/connector/driver/drivertest/drivertest.go b/internal/connector/driver/drivertest/drivertest.go index 05e071eaf..e74bd7f7f 100644 --- a/internal/connector/driver/drivertest/drivertest.go +++ b/internal/connector/driver/drivertest/drivertest.go @@ -1,8 +1,8 @@ //go:build unix // Package drivertest is the shared way to test the connector's one-owner -// rule: a task's process tree, its working directory, and its ledger record -// have a single owner and a single release point (see the rule +// rule: a task's process tree and its ledger record have a single owner and +// a single release point (see the rule // written out in internal/connector/driver/worker.go). // // Cards that start workers or settle records use these helpers rather than diff --git a/internal/connector/driver/worker.go b/internal/connector/driver/worker.go index 30bd1b9d8..164f92501 100644 --- a/internal/connector/driver/worker.go +++ b/internal/connector/driver/worker.go @@ -21,21 +21,20 @@ const pipeWaitDelay = 2 * time.Second // # One owner, one release point // -// This is the connector's rule for a task's process tree, its working -// directory, and its ledger record. All three belong to one owner — the -// attempt — and are released at one point, in this order: +// This is the connector's rule for a task's process tree and its ledger +// record. Both belong to one owner — the attempt — and are released at one +// point, in this order: // // 1. Every worker starts as the leader of its own process group // (StartWorker), so the tree it makes can be signaled as one. // 2. A cancel, a deadline or a shutdown ends that group: SIGTERM, a bounded // wait, then SIGKILL, by process group id and never by name (Terminate). // 3. The group is then CONFIRMED gone (ConfirmGroupGone). Only after that -// may the attempt be settled, its directory released, and its record -// made terminal. +// may the attempt be settled and its record made terminal. // 4. A group that cannot be confirmed gone — members left, a pid whose // identity cannot be established, a platform that cannot say — leaves the -// record HELD: live in the ledger, its conversation and directory still -// its own, for a person to settle. Never terminal, never released. +// record HELD: live in the ledger, its conversation and a worker slot +// still its own, for a person to settle. Never terminal, never released. // 5. A restart reaps by the same rule (TerminateRecorded, then the same // confirmation), and asks OwnsWorker first: a pid is not an identity, so // ownership is the pid AND the kernel's own start time for it, compared @@ -157,12 +156,12 @@ const pipeWaitDelay = 2 * time.Second // acknowledgement and before any later instruction's, it is never the // worker's own acknowledgement, and a listing the scan limit cut short // adopts nothing. -// - An attempt is settled, its directory released and its record made -// terminal at one point (Dispatcher.release), and only after the group is -// confirmed gone and the ledger has taken the settlement. +// - An attempt is settled and its record made terminal at one point +// (Dispatcher.release), and only after the group is confirmed gone and +// the ledger has taken the settlement. // - An attempt that cannot be confirmed or cannot be settled stays live and -// holds its conversation, its directory and one of the connector's worker -// slots, until a person settles it. +// holds its conversation and one of the connector's worker slots, until a +// person settles it. // // Where this can still be broken: adoption trusts Basecamp's ordering of // replies against this machine's clock for "after the acknowledgement", so a @@ -386,7 +385,7 @@ var ErrGroupOutlivedLeader = errors.New("driver: the recorded process group outl // // - (true, nil): the process is still that worker. It may be signaled. // - (false, nil): it is gone, and its group has no members left. Its record -// may be settled and its directory released. +// may be settled. // - (false, ErrGroupOutlivedLeader): the leader is gone or is now some other // process, and the recorded group still has members — they may be the // worker's children. Nothing may be settled or released. @@ -609,7 +608,7 @@ func groupProbe(pgid int, err error) error { // ConfirmGroupGone is step 3 of the one-owner rule: it answers whether a // worker's process group is gone, and it is what every caller asks before -// settling an attempt or releasing a working directory. +// settling an attempt. // // It signals the group once more — a worker that ignored SIGTERM gets SIGKILL // — then waits up to grace for the last member to go. A group with members diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go index 905a8e616..1b340899d 100644 --- a/internal/connector/ledger.go +++ b/internal/connector/ledger.go @@ -872,8 +872,39 @@ END; // account of it and nothing else. `git worktree list` in the repository // still finds it, and `git worktree remove` still removes it. migrationDropWorktrees, + + // Migration 13. No directory is associated with a project any more. + // + // `routed` becomes `served`, which is what it has always held: whether + // connect.json lists the record's project, never anything about a + // filesystem. Renaming keeps every row's value — a record blocked + // no_route stays blocked no_route, and its holding reply still answers + // for it. + // + // The three path columns go. `events.route` and `tasks.route` held the + // directory a project was routed to and `tasks.work_dir` the directory a + // worker ran in; nothing writes or reads any of them now. The unique + // index over work_dir goes with it, and would have to go regardless: + // with every task in one directory it would admit one live task on the + // whole machine. + // + // Nothing on disk is touched. A directory a connector ran work in is + // still there, still whatever the worker left in it. + migrationDropRoutePaths, } +// migrationDropRoutePaths is migration 13: the route's path, everywhere the +// ledger held it. The index is dropped before its column, which is what +// SQLite requires. +const migrationDropRoutePaths = ` +ALTER TABLE events RENAME COLUMN routed TO served; +ALTER TABLE events DROP COLUMN route; + +DROP INDEX IF EXISTS tasks_live_work_dir; +ALTER TABLE tasks DROP COLUMN work_dir; +ALTER TABLE tasks DROP COLUMN route; +` + // migrationWorktrees is migration 9 as it shipped: the worktrees a task ran // in. Nothing reads it — migration 12 drops the table — and it is kept here // only because a migration that has been applied is never taken out of the diff --git a/internal/connector/ledger_admission.go b/internal/connector/ledger_admission.go index f27d43b97..82f7e9746 100644 --- a/internal/connector/ledger_admission.go +++ b/internal/connector/ledger_admission.go @@ -146,8 +146,7 @@ func (a Admission) commit(ctx context.Context, v admission.Verdict, state Record {column: "conversation_key", value: v.ConversationKey}, {column: "reply_kind", value: string(reply.Kind)}, {column: "reply_recording_id", value: reply.RecordingID}, - {column: "routed", value: v.Routed}, - {column: "route", value: v.Route}, + {column: "served", value: v.Served}, {column: "class", value: v.Class}, {column: "recording_url", value: v.RecordingURL}, {column: "requester_id", value: v.RequesterID}, diff --git a/internal/connector/ledger_admission_test.go b/internal/connector/ledger_admission_test.go index a540a8562..aea8d2462 100644 --- a/internal/connector/ledger_admission_test.go +++ b/internal/connector/ledger_admission_test.go @@ -5,9 +5,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -61,8 +63,7 @@ func admittedVerdict(id, revision int64, key string) admission.Verdict { Acknowledge: true, ConversationKey: key, Reply: &admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: 10304028989}, - Routed: true, - Route: "/work/connector", + Served: true, Class: "internal", RecordingURL: "https://app.basecamp.com/2914079/buckets/48699913/recordings/10304028972", Snapshot: &admission.Snapshot{ @@ -163,8 +164,7 @@ func TestAdmissionCommitWritesTheVerdictOntoTheRecord(t *testing.T) { assert.Equal(t, "recording:10304028989", d.ConversationKey) assert.Equal(t, "comment", d.ReplyKind) assert.Equal(t, int64(10304028989), d.ReplyRecordingID) - assert.True(t, d.Routed) - assert.Equal(t, "/work/connector", d.Route) + assert.True(t, d.Served) assert.Equal(t, "internal", d.Class) assert.Equal(t, v.RecordingURL, d.RecordingURL) assert.Equal(t, adapterOperatorID, d.RequesterID) @@ -639,7 +639,7 @@ func TestRunAdmissionDecidesWhatIntakeHandsOver(t *testing.T) { admitter, err := admission.NewAdmitter(admission.Policy{ AgentID: adapterAgentID, Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, - Projects: map[int64]admission.Route{adapterBucketID: {Path: "/work/connector", Class: "internal"}}, + Projects: map[int64]admission.Project{adapterBucketID: {Class: "internal"}}, }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}) require.NoError(t, err) @@ -740,3 +740,83 @@ func mentionMarkup(id int64) string { sgid := base64.RawURLEncoding.EncodeToString([]byte(payload)) return `` } + +// A connect.json that cannot be read holds its records rather than discarding +// them or answering that the project is not served, and a person's +// redispatch runs them once the file is back. +// +// Waiting for a person is what every other blocked record does, and it is +// what this asserts — deliberately, and after taking an automatic sweep back +// out. A retry that offers due blocked records is a scheduler of its own, +// with its own claiming, and it turned out to re-decide five other blocked +// reasons that have nothing to do with this change. It is carded rather than +// carried here, so nothing in the code or the comments promises a timer that +// does not run. +func TestAnUnreadableConfigHoldsItsRecordsUntilTheFileAndAPersonAreBack(t *testing.T) { + ledger := newTestLedger(t) + queue, err := NewQueue(10, 100) + require.NoError(t, err) + + reads := &adapterReads{summaries: map[int64]*basecamp.RecordingSummary{}} + reads.summaries[501] = &basecamp.RecordingSummary{ + ID: 501, Status: "active", Type: "Todo", Title: "A to-do", + AppURL: "https://app.basecamp.com/2914079/buckets/48699913/todos/501", + Bucket: &basecamp.Bucket{ID: adapterBucketID}, + Creator: &basecamp.Person{ID: adapterOperatorID}, + Content: mentionMarkup(adapterAgentID) + "please look", + MentionedPersonIDs: []int64{adapterAgentID}, + UpdatedAt: time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC), + } + + // Atomic, not a plain bool: RunAdmission reads this from a worker + // goroutine while the test writes it, and an unsynchronised pair like + // that is a race whether or not the detector happens to catch it on a + // given run — which is exactly how a mystery flake reaches somebody + // else's PR (Copilot on #765). + var broken atomic.Bool + broken.Store(true) + admitter, err := admission.NewAdmitter(admission.Policy{ + AgentID: adapterAgentID, + Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, + }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}, + admission.WithServed(func() (map[int64]admission.Project, error) { + if broken.Load() { + return nil, errors.New("connect.json cannot be read") + } + return map[int64]admission.Project{adapterBucketID: {Class: "internal"}}, nil + })) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- RunAdmission(ctx, AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Workers: 1}) + }() + t.Cleanup(func() { cancel(); <-done }) + + ev := testEvent(1) + ev.EventType, ev.Kind, ev.RecordingID = "todo.created", "todo_created", 501 + _, err = ledger.RecordSeen(ctx, ev, LanePoll) + require.NoError(t, err) + require.NoError(t, queue.Offer(ctx, ev.ID)) + + require.Eventually(t, func() bool { + return getRecord(t, ledger, ev.ID).State == StateBlocked + }, 5*time.Second, 10*time.Millisecond, "held while the file cannot be read") + held := getRecord(t, ledger, ev.ID) + require.Equal(t, string(admission.ReasonConfigUnreadable), held.Reason) + assert.NotEqual(t, StateDiscarded, held.State, "a discard is the one outcome repairing the file could not undo") + + // The operator repairs the file and redispatches, which is the remedy + // for a blocked record and the one the holding reply names. + broken.Store(false) + res, err := ledger.Redispatch(ctx, ev.ID, "local:tester", []int64{adapterBucketID}) + require.NoError(t, err) + require.True(t, res.Rerun, "a blocked record's prerequisite is run again") + require.NoError(t, queue.Offer(ctx, ev.ID)) + + require.Eventually(t, func() bool { + return getRecord(t, ledger, ev.ID).State == StateAdmitted + }, 5*time.Second, 10*time.Millisecond, "and the work that was held runs") +} diff --git a/internal/connector/ledger_decisions.go b/internal/connector/ledger_decisions.go index b28962fb1..d008ca56d 100644 --- a/internal/connector/ledger_decisions.go +++ b/internal/connector/ledger_decisions.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strings" "time" @@ -143,26 +144,28 @@ func (w LiveWorker) Identity() driver.Process { // // - completed with outcome unknown or failed: the task's token is // superseded; admitted at once when the task has ended, otherwise when it -// ends. Refused without a snapshot or a route. -// - held with its snapshot and route and no blocking reason: admitted. +// ends. Refused without a snapshot, or in a project connect.json does +// not serve. +// - held with its snapshot, in a served project and with no blocking +// reason: admitted. // - blocked, or held over a blocking reason: authorized as blocked, and // Rerun asks the caller to run what blocked it. // - succeeded, discarded, and anything live (seen, admitted, queued, // dispatched) are refused with ErrDecisionRefused. -func (l *Ledger) Redispatch(ctx context.Context, eventID int64, by string) (RedispatchResult, error) { +func (l *Ledger) Redispatch(ctx context.Context, eventID int64, by string, served []int64) (RedispatchResult, error) { if strings.TrimSpace(by) == "" { return RedispatchResult{}, errors.New("connector: a redispatch records who authorized it") } var out RedispatchResult err := retryBusy(func() error { var err error - out, err = l.redispatch(ctx, eventID, by) + out, err = l.redispatch(ctx, eventID, by, served) return err }) return out, err } -func (l *Ledger) redispatch(ctx context.Context, eventID int64, by string) (RedispatchResult, error) { +func (l *Ledger) redispatch(ctx context.Context, eventID int64, by string, served []int64) (RedispatchResult, error) { tx, err := l.db.BeginTx(ctx, nil) if err != nil { return RedispatchResult{}, fmt.Errorf("connector: begin redispatch: %w", err) @@ -181,7 +184,17 @@ func (l *Ledger) redispatch(ctx context.Context, eventID int64, by string) (Redi refuse := func(why string) error { return fmt.Errorf("connector: redispatch of event %d %s: %w", eventID, why, ErrDecisionRefused) } - dispatchable := !record.ContentDropped && len(record.Decision.Snapshot) > 0 && record.Decision.Routed && record.Decision.ConversationKey != "" + // Decision.Served is what admission wrote when it decided the record, so + // it says the project was served then; served is what the caller read + // from connect.json for this command. Both, because a redispatch that + // reported success and left the record for a dispatcher that will refuse + // to launch it is worse than one that refuses here (Copilot on #765). + // + // served is a reading, not a lock: `connect setup --unserve` landing + // between that read and this commit is not caught here. Carded. + dispatchable := !record.ContentDropped && len(record.Decision.Snapshot) > 0 && + record.Decision.Served && slices.Contains(served, record.BucketID) && + record.Decision.ConversationKey != "" at := l.now() now := stamp(at) authorize := []assignment{{column: "authorized_at", value: now}, {column: "authorized_by", value: by}} @@ -204,7 +217,7 @@ func (l *Ledger) redispatch(ctx context.Context, eventID int64, by string) (Redi case record.redispatchDecision != 0: return RedispatchResult{}, refuse("already has a redispatch waiting for its task to end") case !dispatchable: - return RedispatchResult{}, refuse("no longer has the snapshot and route a dispatch needs (retention dropped them, or the verdict carried none)") + return RedispatchResult{}, refuse("is missing something a dispatch needs: its content snapshot (retention dropped it, or the verdict carried none), or a project connect.json serves") } if !task.superseded { // The replaced worker is refused by basecamp_connect from here on diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go index 2a42c5d44..18addbf81 100644 --- a/internal/connector/ledger_dispatch.go +++ b/internal/connector/ledger_dispatch.go @@ -565,7 +565,7 @@ func (l *Ledger) Dispatch(ctx context.Context, token string, agentID int64) (*Ta // Instruction is what get_dispatch hands a worker. It is an allowlist: every // field is named here, and nothing the ledger holds reaches a worker unless -// it is one of them. No route, no feed position, no token. +// it is one of them. No directory, no feed position, no token. type Instruction struct { EventID int64 `json:"event_id"` EventType string `json:"event_type"` diff --git a/internal/connector/ledger_events.go b/internal/connector/ledger_events.go index 4b33fc8f8..fd96d27c7 100644 --- a/internal/connector/ledger_events.go +++ b/internal/connector/ledger_events.go @@ -60,11 +60,12 @@ type Decision struct { ConversationKey string ReplyKind string ReplyRecordingID int64 - Routed bool - Route string - Class string - RecordingURL string - RequesterID int64 + // Served is whether connect.json served the record's project when + // admission decided it. + Served bool + Class string + RecordingURL string + RequesterID int64 // Snapshot is the recording's content as admission read it, JSON. An // admitted verdict writes it, as admitted or queued; it stays through // dispatch and completion until retention drops it, and any move to @@ -486,7 +487,7 @@ SET details = NULL, event_type = '', kind = '', action = '', bucket_id = 0, creator_id = 0, performed_by_id = NULL, recording_id = 0, actor_type = '', visible_to_clients = NULL, content_dropped = 1, updated_at = updated_at, snapshot = NULL, trigger_name = '', acknowledge = 0, conversation_key = '', - reply_kind = '', reply_recording_id = 0, routed = 0, route = '', class = '', + reply_kind = '', reply_recording_id = 0, served = 0, class = '', recording_url = '', requester_id = 0 WHERE content_dropped = 0 AND redispatch_decision IS NULL AND ((state = ? AND updated_at < ?) OR (state = ? AND updated_at < ?))`, @@ -507,7 +508,7 @@ SELECT id, state, reason, lane, event_type, kind, action, bucket_id, creator_id, performed_by_id, recording_id, details, actor_type, visible_to_clients, created_at, seen_at, updated_at, content_dropped, revision, decided_at, blocked_at, retry_at, trigger_name, acknowledge, conversation_key, - reply_kind, reply_recording_id, routed, route, class, recording_url, + reply_kind, reply_recording_id, served, class, recording_url, requester_id, snapshot FROM events` @@ -526,7 +527,7 @@ func scanRecords(rows *sql.Rows) ([]Record, error) { visibleToClients sql.NullBool decidedAt, blockedAt sql.NullString retryAt sql.NullString - acknowledge, routed int + acknowledge, served int snapshot []byte d = &r.Decision ) @@ -535,7 +536,7 @@ func scanRecords(rows *sql.Rows) ([]Record, error) { &details, &r.ActorType, &visibleToClients, &createdAt, &seenAt, &updatedAt, &contentDropped, &r.Revision, &decidedAt, &blockedAt, &retryAt, &d.Trigger, &acknowledge, &d.ConversationKey, &d.ReplyKind, - &d.ReplyRecordingID, &routed, &d.Route, &d.Class, &d.RecordingURL, + &d.ReplyRecordingID, &served, &d.Class, &d.RecordingURL, &d.RequesterID, &snapshot); err != nil { return nil, fmt.Errorf("connector: scan event record: %w", err) } @@ -563,7 +564,7 @@ func scanRecords(rows *sql.Rows) ([]Record, error) { return nil, err } r.ContentDropped = contentDropped != 0 - d.Acknowledge, d.Routed = acknowledge != 0, routed != 0 + d.Acknowledge, d.Served = acknowledge != 0, served != 0 if len(snapshot) > 0 { d.Snapshot = json.RawMessage(snapshot) } diff --git a/internal/connector/ledger_hold.go b/internal/connector/ledger_hold.go index 05910d1a4..6fc098c63 100644 --- a/internal/connector/ledger_hold.go +++ b/internal/connector/ledger_hold.go @@ -52,8 +52,8 @@ import ( // transaction that ends the task. One live task per conversation keeps // the new task from starting before then. // 6. Admitted means dispatchable. A redispatch admits only a record that -// still has its snapshot and route; anything else is decided again by -// admission, whose verdict stands. +// still has its snapshot and a served project; anything else is decided +// again by admission, whose verdict stands. // 7. Shadow promote and import are atomic under a crash: each is one ledger // transaction, and promote exposes the shadow ledger at the normal path // only after its hold committed, by one rename (promote.go). diff --git a/internal/connector/ledger_migrations_test.go b/internal/connector/ledger_migrations_test.go index ae5fe33d7..bcf8ff960 100644 --- a/internal/connector/ledger_migrations_test.go +++ b/internal/connector/ledger_migrations_test.go @@ -11,33 +11,39 @@ import ( "github.com/stretchr/testify/require" ) -// Worktrees are gone, and the ledger forgets them: migration 12 drops the -// table migration 9 made. Migration 9 itself stays in the list, unedited — -// a ledger that has applied it never sees it renumbered, and a fresh ledger -// walks the same numbers to reach 12 — so the shape this proves is "made, -// then dropped", on a ledger that really did run 9. -func TestTheWorktreesTableIsDroppedFromALedgerThatHadOne(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "state", "connector.db") - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) +// migrationsBeforeWorktreesDropped is the last migration a ledger that still +// had a worktrees table had applied. Migration 12 drops the table, so a test +// that wants a row in it stops one short. +const migrationsBeforeWorktreesDropped = 11 + +// migrationsBeforeRoutePathsDropped is the last migration a ledger that still +// carried events.routed, events.route, tasks.route and tasks.work_dir had +// applied. Migration 13 renames the first and drops the rest. +const migrationsBeforeRoutePathsDropped = 12 +// applyMigrationsThrough opens a raw database at path and applies the first n +// migrations, recording each, as an older build would have left it. It leaves +// the handle open for the caller to seed rows through. +func applyMigrationsThrough(t *testing.T, path string, n int) *sql.DB { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) old, err := sql.Open("sqlite", ledgerDSN(path, true)) require.NoError(t, err) - _, err = old.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`) + _, err = old.ExecContext(context.Background(), `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`) require.NoError(t, err) - for i := range len(migrations) - 1 { - _, err = old.ExecContext(ctx, migrations[i]) + for i := range n { + _, err = old.ExecContext(context.Background(), migrations[i]) require.NoError(t, err, "migration %d", i+1) - _, err = old.ExecContext(ctx, `INSERT INTO schema_migrations (version, applied_at) VALUES (?, 'then')`, i+1) + _, err = old.ExecContext(context.Background(), `INSERT INTO schema_migrations (version, applied_at) VALUES (?, 'then')`, i+1) require.NoError(t, err) } - // A ledger with a worktree in it, as one that ran with them on would have. - _, err = old.ExecContext(ctx, ` -INSERT INTO worktrees (path, work_dir, route, repository, branch, base_commit, originating_event_id, state, retained_reason, created_at, retained_at) -VALUES ('/w/one', '/w/one/app', '/repo/app', '/repo', 'basecamp-connect/1-a1b2c3', 'abc', 1, 'retained', 'dirty', 'then', 'then')`) - require.NoError(t, err, "migration 9 made the table this row goes in") - require.NoError(t, old.Close()) + return old +} +// openUpgraded makes the file private, as the connector's own open would, +// then opens it through OpenLedger so the remaining migrations run. +func openUpgraded(t *testing.T, path string) *Ledger { + t.Helper() // The connector's own open makes the file private; a raw sql.Open does // not, and the privacy check refuses what it finds. for _, name := range []string{path, path + "-wal", path + "-shm"} { @@ -45,16 +51,98 @@ VALUES ('/w/one', '/w/one/app', '/repo/app', '/repo', 'basecamp-connect/1-a1b2c3 require.NoError(t, os.Chmod(name, 0o600)) } } - ledger, err := OpenLedger(path) require.NoError(t, err) t.Cleanup(func() { _ = ledger.Close() }) - version, err := ledger.SchemaVersion(ctx) + version, err := ledger.SchemaVersion(context.Background()) require.NoError(t, err) assert.Equal(t, len(migrations), version, "the upgrade ran") + return ledger +} + +// Worktrees are gone, and the ledger forgets them: migration 12 drops the +// table migration 9 made. Migration 9 itself stays in the list, unedited — +// a ledger that has applied it never sees it renumbered, and a fresh ledger +// walks the same numbers to reach 12 — so the shape this proves is "made, +// then dropped", on a ledger that really did run 9. +func TestTheWorktreesTableIsDroppedFromALedgerThatHadOne(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state", "connector.db") + old := applyMigrationsThrough(t, path, migrationsBeforeWorktreesDropped) + // A ledger with a worktree in it, as one that ran with them on would have. + _, err := old.ExecContext(ctx, ` +INSERT INTO worktrees (path, work_dir, route, repository, branch, base_commit, originating_event_id, state, retained_reason, created_at, retained_at) +VALUES ('/w/one', '/w/one/app', '/repo/app', '/repo', 'basecamp-connect/1-a1b2c3', 'abc', 1, 'retained', 'dirty', 'then', 'then')`) + require.NoError(t, err, "migration 9 made the table this row goes in") + require.NoError(t, old.Close()) + + ledger := openUpgraded(t, path) var left int require.NoError(t, ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'worktrees%'`).Scan(&left)) assert.Zero(t, left, "the table, its indexes and its trigger are all gone") } + +// No directory is associated with a project any more, and the ledger stops +// holding one: migration 13 renames events.routed to served — the value it +// always held — and drops the three columns that named a path, with the +// unique index over the last of them. +// +// The rename is the part that has to keep a value. A record blocked no_route +// on a ledger written by an older build is still blocked no_route after the +// upgrade, and one admitted in a served project is still served, or the +// holding replies and their retractions would answer for the wrong records. +func TestTheRoutePathsAreDroppedFromALedgerThatHadThem(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state", "connector.db") + old := applyMigrationsThrough(t, path, migrationsBeforeRoutePathsDropped) + + // Two records an older build wrote: one in a routed project, one blocked + // for want of a route. + _, err := old.ExecContext(ctx, ` +INSERT INTO events (id, state, reason, lane, event_type, kind, action, bucket_id, creator_id, recording_id, + created_at, seen_at, updated_at, routed, route, conversation_key) +VALUES (1, 'admitted', '', 'import', 'comment.created', '', '', 48699913, 26909558, 10304028972, + ?1, ?1, ?1, 1, '/work/app', 'recording:1'), + (2, 'blocked', 'no_route', 'import', 'comment.created', '', '', 777, 26909558, 10304028973, + ?1, ?1, ?1, 0, '', '')`, "2026-09-17T11:00:00.000000000Z") + require.NoError(t, err, "migration 4 made the columns these rows use") + _, err = old.ExecContext(ctx, ` +INSERT INTO tasks (token_sha256, created_at, conversation_key, route, work_dir, driver) +VALUES ('abc', '2026-09-17T11:00:00.000000000Z', 'recording:1', '/work/app', '/work/app/wt', 'claude')`) + require.NoError(t, err, "migration 7 made the columns this row uses") + require.NoError(t, old.Close()) + + ledger := openUpgraded(t, path) + + var served int + var reason string + require.NoError(t, ledger.db.QueryRowContext(ctx, `SELECT served, reason FROM events WHERE id = 1`).Scan(&served, &reason)) + assert.Equal(t, 1, served, "a record admitted in a routed project is served") + require.NoError(t, ledger.db.QueryRowContext(ctx, `SELECT served, reason FROM events WHERE id = 2`).Scan(&served, &reason)) + assert.Equal(t, 0, served) + assert.Equal(t, "no_route", reason, "the reason a holding reply answers for is read as it was written") + + for _, q := range []string{ + `SELECT route FROM events`, + `SELECT route FROM tasks`, + `SELECT work_dir FROM tasks`, + `SELECT routed FROM events`, + } { + _, err := ledger.db.ExecContext(ctx, q) + assert.Error(t, err, q) + } + + var index int + require.NoError(t, ledger.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE name = 'tasks_live_work_dir'`).Scan(&index)) + assert.Zero(t, index, "the index that would have admitted one live task on the whole machine") + + // And the rows themselves are still there, read through the ledger. + record, ok, err := ledger.Get(ctx, 2) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, StateBlocked, record.State) + assert.False(t, record.Decision.Served) +} diff --git a/internal/connector/ledger_status.go b/internal/connector/ledger_status.go index 445401001..7b82f1c3f 100644 --- a/internal/connector/ledger_status.go +++ b/internal/connector/ledger_status.go @@ -164,7 +164,6 @@ type TaskStatus struct { AttemptID string `json:"attempt_id"` State string `json:"state"` Driver string `json:"driver"` - WorkDir string `json:"work_dir"` PID int `json:"pid,omitempty"` PGID int `json:"pgid,omitempty"` // ProcessStartedAt is the start time recorded with the pid: with it, the @@ -434,7 +433,7 @@ SELECT func statusTasks(ctx context.Context, tx *sql.Tx, s *Status) error { rows, err := tx.QueryContext(ctx, ` -SELECT t.id, a.id, a.state, a.driver, t.work_dir, COALESCE(a.pid, 0), COALESCE(a.pgid, 0), a.process_started, +SELECT t.id, a.id, a.state, a.driver, COALESCE(a.pid, 0), COALESCE(a.pgid, 0), a.process_started, COALESCE(a.taker_pid, 0), COALESCE(a.taker_pgid, 0), a.taker_started, a.taker_unaccounted, a.launched_at, t.deadline_at FROM attempts a JOIN tasks t ON t.id = a.task_id WHERE a.state <> 'ended' ORDER BY a.launched_at, a.id`) @@ -450,7 +449,7 @@ WHERE a.state <> 'ended' ORDER BY a.launched_at, a.id`) started sql.NullString taken sql.NullString ) - if err := rows.Scan(&t.TaskID, &t.AttemptID, &t.State, &t.Driver, &t.WorkDir, &t.PID, &t.PGID, &started, + if err := rows.Scan(&t.TaskID, &t.AttemptID, &t.State, &t.Driver, &t.PID, &t.PGID, &started, &t.TakerPID, &t.TakerPGID, &taken, &t.TakerUnaccounted, &launched, &deadline); err != nil { _ = rows.Close() return err diff --git a/internal/connector/ledger_tasks.go b/internal/connector/ledger_tasks.go index 25e0f0723..73264aee1 100644 --- a/internal/connector/ledger_tasks.go +++ b/internal/connector/ledger_tasks.go @@ -30,10 +30,12 @@ import ( // record to dispatched, and before the driver is asked to start anything. // A follow-up is written exposed (ExposeEvent) before a prompt about it is // sent. -// 2. One live task per conversation, one per working directory, one live -// attempt per task, and (migration 5's task_events_one_live_task) one live -// task per event. Unique partial indexes, so two dispatchers on one ledger -// cannot both win. +// 2. One live task per conversation, one live attempt per task, and +// (migration 5's task_events_one_live_task) one live task per event. +// Unique partial indexes, so two dispatchers on one ledger cannot both +// win. There is no rule about directories: every task runs in the +// connector's own, and two workers in one directory are managed by the +// people running them until sandboxes are real. // 3. An ended task has no valid token and no live events. Ending a task, // superseding its token and retiring its events are one transaction, and // a trigger refuses the end without the supersession, so a worker that @@ -52,6 +54,12 @@ import ( // id beside an unknown outcome and leaves the outcome unknown. // 7. Attempt states move forward only: launching → running → ended, or // launching → ended. +// +// migrationTasksAndAttempts is migration 7 as it shipped. Its route and +// work_dir columns and the tasks_live_work_dir index over them are gone — +// migration 13 drops them — and they are still written here because a +// migration that has been applied is never edited: a fresh ledger walks the +// same statements a ledger already at 7 walked. Do not edit it. const migrationTasksAndAttempts = ` ALTER TABLE tasks ADD COLUMN conversation_key TEXT NOT NULL DEFAULT ''; ALTER TABLE tasks ADD COLUMN route TEXT NOT NULL DEFAULT ''; @@ -165,12 +173,10 @@ const ReasonSpawnFailed = "spawn_failed" // Errors from the task ledger. var ( // ErrNotStartable is a launch for a record that is not waiting for a - // worker: not admitted or queued, without its snapshot or route, on a - // conversation or working directory that already has a live task. + // worker: not admitted or queued, without its snapshot, in a project + // connect.json does not serve, or on a conversation that already has a + // live task. ErrNotStartable = errors.New("the record is not waiting for a worker") - // ErrWorkDirMismatch is a launch naming a working directory the record - // does not carry. - ErrWorkDirMismatch = errors.New("the working directory is not the one the record carries") // ErrNoLiveAttempt is a write for an attempt that has ended or never was. ErrNoLiveAttempt = errors.New("no live attempt by that id") ) @@ -243,12 +249,14 @@ type CommittedVerdict struct { type LaunchSpec struct { // EventID is the originating event: an admitted or queued record. EventID int64 - // Route is the approved directory; it must be the route the record - // carries. - Route string - // WorkDir is the directory the worker works in: Route itself. Empty - // means Route. One live task holds a working directory. - WorkDir string + // Served is the served projects the caller decided this launch from, + // already cut to the run's --project scope. For the dispatcher it is the + // one reading of connect.json that this pass took, handed down — not a + // fresh read, and not a promise about what the file says at the moment + // the transaction commits. Records on the originating record's + // conversation join its task only from its own project, and only while + // that project is among these. + Served []int64 // Driver is the driver's name. Driver string // Deadline is how long the task may run; zero for none. @@ -266,8 +274,6 @@ type Launch struct { // event is exposed; the rest wait at delivery admitted. EventIDs []int64 ConversationKey string - Route string - WorkDir string Driver string LaunchedAt time.Time // DeadlineAt is zero when the task has no deadline. @@ -279,11 +285,8 @@ type Launch struct { // same conversation that wait for a worker join the task at delivery // admitted. func (l *Ledger) LaunchTask(ctx context.Context, spec LaunchSpec) (Launch, error) { - if spec.WorkDir == "" { - spec.WorkDir = spec.Route - } - if spec.Route == "" || spec.Driver == "" { - return Launch{}, errors.New("connector: a launch needs a route and a driver") + if spec.Driver == "" { + return Launch{}, errors.New("connector: a launch needs a driver") } attemptID, err := newAttemptID() if err != nil { @@ -312,20 +315,38 @@ func (l *Ledger) launchTask(ctx context.Context, spec LaunchSpec, attemptID stri switch { case record.State != StateAdmitted && record.State != StateQueued, record.ContentDropped, len(record.Decision.Snapshot) == 0, - !record.Decision.Routed, record.Decision.ConversationKey == "": + !record.Decision.Served, record.Decision.ConversationKey == "": return Launch{}, fmt.Errorf("connector: launch event %d (%s): %w", spec.EventID, record.State, ErrNotStartable) - case record.Decision.Route != spec.Route: - return Launch{}, fmt.Errorf("connector: launch event %d in %q: %w", spec.EventID, spec.Route, ErrWorkDirMismatch) + case !slices.Contains(spec.Served, record.BucketID): + // Two different questions, and neither is "what does the file say + // right now". Decision.Served is what admission wrote when it + // decided the record, so it says the project was served then; + // spec.Served is the set its caller decided this launch from. + // + // What this buys, said plainly, because the comment here used to + // claim more. For the dispatcher it is redundant with the startable + // query, which filtered on the same slice — belt and braces at the + // ledger's own boundary, so a launch that names a set not covering + // its record is refused rather than trusted, and one that names no + // set authorizes nothing rather than defaulting open. + // + // What it does NOT do is close the window between reading + // connect.json and committing this transaction. An unserve landing + // in that window still launches this one task; a follow-up is + // stopped at the next tick by taskRun.authorized. That race is + // pre-existing in kind — the route had it too — and holding the + // setup lock across selection and commit is carded, not done here. + return Launch{}, fmt.Errorf("connector: launch event %d in project %d, which is not served: %w", spec.EventID, record.BucketID, ErrNotStartable) } var busy bool if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS (SELECT 1 FROM tasks WHERE ended_at IS NULL AND (conversation_key = ? OR work_dir = ?)) +SELECT EXISTS (SELECT 1 FROM tasks WHERE ended_at IS NULL AND conversation_key = ?) OR EXISTS (SELECT 1 FROM task_events WHERE event_id = ? AND retired_at IS NULL)`, - record.Decision.ConversationKey, spec.WorkDir, spec.EventID).Scan(&busy); err != nil { + record.Decision.ConversationKey, spec.EventID).Scan(&busy); err != nil { return Launch{}, fmt.Errorf("connector: launch event %d: %w", spec.EventID, err) } if busy { - return Launch{}, fmt.Errorf("connector: launch event %d: a live task holds its conversation or working directory: %w", spec.EventID, ErrNotStartable) + return Launch{}, fmt.Errorf("connector: launch event %d: a live task holds its conversation: %w", spec.EventID, ErrNotStartable) } now := l.now() @@ -339,7 +360,10 @@ SELECT EXISTS (SELECT 1 FROM tasks WHERE ended_at IS NULL AND (conversation_key // The originating event first, then every other record on the // conversation that waits for a worker. createTask dispatches them all // and refuses an event a live task already carries. - joinable, err := joinableOn(ctx, tx, record.Decision.ConversationKey, spec.Route, spec.EventID) + // In the originating record's own project, and in one this run serves + // now: the record was chosen from the served set, and what joins it is + // held to the same set rather than to what admission wrote on each row. + joinable, err := joinableOn(ctx, tx, record.Decision.ConversationKey, record.BucketID, spec.Served, spec.EventID) if err != nil { return Launch{}, err } @@ -349,8 +373,8 @@ SELECT EXISTS (SELECT 1 FROM tasks WHERE ended_at IS NULL AND (conversation_key } taskID := grant.ID if _, err := tx.ExecContext(ctx, ` -UPDATE tasks SET conversation_key = ?, route = ?, work_dir = ?, driver = ?, originating_event_id = ?, deadline_at = ? -WHERE id = ?`, record.Decision.ConversationKey, spec.Route, spec.WorkDir, spec.Driver, spec.EventID, deadline, taskID); err != nil { +UPDATE tasks SET conversation_key = ?, driver = ?, originating_event_id = ?, deadline_at = ? +WHERE id = ?`, record.Decision.ConversationKey, spec.Driver, spec.EventID, deadline, taskID); err != nil { return Launch{}, fmt.Errorf("connector: create task for %d: %w", spec.EventID, err) } if _, err := tx.ExecContext(ctx, ` @@ -374,8 +398,6 @@ WHERE task_id = ? AND event_id = ?`, nowStamp, attemptID, taskID, spec.EventID); AttemptID: attemptID, EventIDs: append([]int64{spec.EventID}, joined...), ConversationKey: record.Decision.ConversationKey, - Route: spec.Route, - WorkDir: spec.WorkDir, Driver: spec.Driver, LaunchedAt: now, DeadlineAt: deadlineAt, @@ -402,15 +424,33 @@ func guardFor(acknowledge bool) string { // carries what a dispatch needs and no live task holds it. const startableCondition = ` e.state IN ('admitted', 'queued') AND e.content_dropped = 0 AND e.snapshot IS NOT NULL -AND e.routed = 1 AND e.conversation_key <> '' +AND e.served = 1 AND e.conversation_key <> '' AND NOT EXISTS (SELECT 1 FROM task_events te WHERE te.event_id = e.id AND te.retired_at IS NULL)` -// joinableOn lists the records on key, other than except, that wait for a -// worker and carry route, oldest first. A record admitted under another route -// (connect.json changed while a task ran) waits for a task in its own -// directory rather than riding along in this one. -func joinableOn(ctx context.Context, tx *sql.Tx, key, route string, except int64) ([]int64, error) { - rows, err := tx.QueryContext(ctx, `SELECT e.id FROM events e WHERE e.conversation_key = ? AND e.route = ? AND e.id <> ? AND `+startableCondition+` ORDER BY e.id`, key, route, except) +// joinableOn lists the records on key, in bucket, other than except, that +// wait for a worker, oldest first. served narrows it further to the projects +// connect.json serves right now, already cut to the run's --project scope; +// empty serves nothing. +// +// The conversation is not the whole of it, and this is the reason (Copilot on +// #765). A conversation key is the recording's or the Campfire's, never the +// bucket's, so two records on one conversation can sit in two projects — a +// recording moved between them is the ordinary way, and connect.json serving +// only one of them is the ordinary case. A task is authorized against the +// project its originating record was in; handing its worker an event from +// another project would make the served list, which is the whole local answer +// to which projects may drive this agent, leak at the one seam it exists to +// hold. +// +// The record's own served bit is not that authorization either: admission +// wrote it when the record was decided, so it says the project was served +// then. served here is read at join time, so a project the operator has +// stopped serving stops feeding a task that is already running. +func joinableOn(ctx context.Context, tx *sql.Tx, key string, bucket int64, served []int64, except int64) ([]int64, error) { + if !slices.Contains(served, bucket) { + return nil, nil + } + rows, err := tx.QueryContext(ctx, `SELECT e.id FROM events e WHERE e.conversation_key = ? AND e.bucket_id = ? AND e.id <> ? AND `+startableCondition+` ORDER BY e.id`, key, bucket, except) if err != nil { return nil, fmt.Errorf("connector: find follow-ups on %s: %w", key, err) } @@ -426,11 +466,12 @@ func joinableOn(ctx context.Context, tx *sql.Tx, key, route string, except int64 return ids, rows.Err() } -// joinConversation puts every record on key that waits for a worker onto the -// live task taskID at delivery admitted, dispatched, as createTask would have, -// and returns their ids, oldest first. -func (l *Ledger) joinConversation(ctx context.Context, tx *sql.Tx, taskID int64, key, route string) ([]int64, error) { - ids, err := joinableOn(ctx, tx, key, route, 0) +// joinConversation puts every record on key, in bucket and in a project +// served now, that waits for a worker onto the live task taskID at delivery +// admitted, dispatched, as createTask would have, and returns their ids, +// oldest first. +func (l *Ledger) joinConversation(ctx context.Context, tx *sql.Tx, taskID int64, key string, bucket int64, served []int64) ([]int64, error) { + ids, err := joinableOn(ctx, tx, key, bucket, served, 0) if err != nil { return nil, err } @@ -456,13 +497,17 @@ func (l *Ledger) joinConversation(ctx context.Context, tx *sql.Tx, taskID int64, return ids, nil } -// JoinConversation puts the records on a live task's conversation that wait -// for a worker onto the task, at delivery admitted, and returns their ids. A -// task that has ended takes none: they start a task of their own. Nor does a +// JoinConversation puts the records on a live task's conversation, in the +// task's own project, that wait for a worker onto the task, at delivery +// admitted, and returns their ids. served is the projects connect.json serves +// now, already cut to the run's --project scope; a task whose project is not +// among them takes nothing. +// +// A task that has ended takes none: they start a task of their own. Nor does a // task a redispatch superseded while it runs: its worker's token is refused, // so what joined it could only end unknown. Nor does any task while the hold // marker stands: joining is a hand-off to a worker (ledger_hold.go). -func (l *Ledger) JoinConversation(ctx context.Context, taskID int64) ([]int64, error) { +func (l *Ledger) JoinConversation(ctx context.Context, taskID int64, served []int64) ([]int64, error) { var out []int64 err := retryBusy(func() error { tx, err := l.db.BeginTx(ctx, nil) @@ -470,20 +515,28 @@ func (l *Ledger) JoinConversation(ctx context.Context, taskID int64) ([]int64, e return fmt.Errorf("connector: begin join: %w", err) } defer func() { _ = tx.Rollback() }() - var key, route string - switch err := tx.QueryRowContext(ctx, `SELECT conversation_key, route FROM tasks WHERE id = ? AND ended_at IS NULL AND superseded_at IS NULL - AND NOT EXISTS (SELECT 1 FROM hold_marker)`, taskID).Scan(&key, &route); { + var ( + key string + bucket int64 + ) + // The task's project is its originating record's, read here rather + // than trusted from the caller. That record is dispatched while the + // task is live, so retention has not cleared its bucket. + switch err := tx.QueryRowContext(ctx, `SELECT t.conversation_key, e.bucket_id +FROM tasks t JOIN events e ON e.id = t.originating_event_id +WHERE t.id = ? AND t.ended_at IS NULL AND t.superseded_at IS NULL + AND NOT EXISTS (SELECT 1 FROM hold_marker)`, taskID).Scan(&key, &bucket); { case errors.Is(err, sql.ErrNoRows): out = nil return nil case err != nil: return fmt.Errorf("connector: join task %d: %w", taskID, err) } - if key == "" { + if key == "" || bucket == 0 { out = nil return nil } - ids, err := l.joinConversation(ctx, tx, taskID, key, route) + ids, err := l.joinConversation(ctx, tx, taskID, key, bucket, served) if err != nil { return err } @@ -931,8 +984,6 @@ type LiveAttempt struct { TaskID int64 State AttemptState Driver string - Route string - WorkDir string ConversationKey string Process AttemptProcess // Taker is the process the task token went to, where one took it. Its @@ -952,7 +1003,7 @@ type LiveAttempt struct { // may exist. func (l *Ledger) LiveAttempts(ctx context.Context) ([]LiveAttempt, error) { rows, err := l.db.QueryContext(ctx, ` -SELECT a.id, a.task_id, a.state, a.driver, t.route, t.work_dir, t.conversation_key, +SELECT a.id, a.task_id, a.state, a.driver, t.conversation_key, COALESCE(a.pid, 0), COALESCE(a.pgid, 0), a.process_started, a.session_id, a.launched_at, t.deadline_at, COALESCE(a.taker_pid, 0), COALESCE(a.taker_pgid, 0), a.taker_started, a.taker_unaccounted FROM attempts a JOIN tasks t ON t.id = a.task_id @@ -968,7 +1019,7 @@ WHERE a.state <> 'ended' ORDER BY a.launched_at, a.id`) state, launched string started, deadline, took sql.NullString ) - if err := rows.Scan(&a.AttemptID, &a.TaskID, &state, &a.Driver, &a.Route, &a.WorkDir, &a.ConversationKey, + if err := rows.Scan(&a.AttemptID, &a.TaskID, &state, &a.Driver, &a.ConversationKey, &a.Process.PID, &a.Process.PGID, &started, &a.Process.SessionID, &launched, &deadline, &a.Taker.PID, &a.Taker.PGID, &took, &a.TakerUnaccounted); err != nil { return nil, fmt.Errorf("connector: live attempts: %w", err) @@ -998,7 +1049,7 @@ WHERE a.state <> 'ended' ORDER BY a.launched_at, a.id`) } // StartableRecords returns up to limit records waiting for a worker, the -// oldest per conversation, oldest first, whatever their route. While the hold +// oldest per conversation, oldest first. While the hold // marker stands there are none: the database would refuse their launch // (ledger_hold.go). func (l *Ledger) StartableRecords(ctx context.Context, limit int) ([]Record, error) { @@ -1010,41 +1061,28 @@ func (l *Ledger) StartableRecords(ctx context.Context, limit int) ([]Record, err // place in the window, or a backlog it cannot start starves everything behind // it. type StartableFilter struct { - // Routes are the approved directories by project, connect.json's as they - // are now, already narrowed to --project. A record whose (project, route) - // is not among them is not startable. Empty means nothing is. - Routes map[int64]string - // RouteHeld: a route with a live task holds its directory, so a record on - // it waits. False when every task gets a directory of its own. - RouteHeld bool - Limit int + // Served are the served project ids the caller decided this query from, + // already narrowed to --project — one reading, not a promise about the + // file at any later moment. A record in any other project is not + // startable. Empty means nothing is. + Served []int64 + Limit int } // StartableRecordsWhere is StartableRecords narrowed by f. func (l *Ledger) StartableRecordsWhere(ctx context.Context, f StartableFilter) ([]Record, error) { - if len(f.Routes) == 0 { + if len(f.Served) == 0 { return nil, nil } - buckets := make([]int64, 0, len(f.Routes)) - for bucket := range f.Routes { - buckets = append(buckets, bucket) - } + buckets := slices.Clone(f.Served) slices.Sort(buckets) - var where strings.Builder - var args []any - where.WriteString(" AND (") - for i, bucket := range buckets { - if i > 0 { - where.WriteString(" OR ") - } - where.WriteString("(e.bucket_id = ? AND e.route = ?)") - args = append(args, bucket, f.Routes[bucket]) - } - where.WriteString(")") - if f.RouteHeld { - where.WriteString(" AND NOT EXISTS (SELECT 1 FROM tasks h WHERE h.ended_at IS NULL AND h.route = e.route)") + buckets = slices.Compact(buckets) + args := make([]any, 0, len(buckets)) + for _, bucket := range buckets { + args = append(args, bucket) } - return l.startable(ctx, where.String(), args, f.Limit) + where := " AND e.bucket_id IN (" + placeholders(len(buckets)) + ")" + return l.startable(ctx, where, args, f.Limit) } // startable runs the startable query with an extra condition. extra is built @@ -1086,21 +1124,21 @@ GROUP BY e.conversation_key ORDER BY MIN(e.id) LIMIT ?` return out, nil } -// StrandedRecords counts the records waiting for a worker whose (project, -// route) no approved pair covers: work admitted under a route connect.json no -// longer has, which nothing will start until a person routes it again or -// discards it. +// StrandedRecords counts the records waiting for a worker in a project +// connect.json no longer serves: work admitted while the project was served, +// which nothing will start until a person serves it again or discards the +// record. // buckets is the run's --project scope: work in a project this run does not // hear is another run's to dispatch, not stranded, so it is not counted. -func (l *Ledger) StrandedRecords(ctx context.Context, approved map[int64]string, buckets []int64) (int, error) { +func (l *Ledger) StrandedRecords(ctx context.Context, served []int64, buckets []int64) (int, error) { var where strings.Builder - args := make([]any, 0, 2*len(approved)+len(buckets)) - for bucket, route := range approved { - where.WriteString(" AND NOT (e.bucket_id = ? AND e.route = ?)") - args = append(args, bucket, route) + args := make([]any, 0, len(served)+len(buckets)) + for _, bucket := range served { + where.WriteString(" AND e.bucket_id <> ?") + args = append(args, bucket) } if len(buckets) > 0 { - where.WriteString(" AND e.bucket_id IN (" + strings.TrimSuffix(strings.Repeat("?, ", len(buckets)), ", ") + ")") + where.WriteString(" AND e.bucket_id IN (" + placeholders(len(buckets)) + ")") for _, bucket := range buckets { args = append(args, bucket) } diff --git a/internal/connector/ledger_tasks_test.go b/internal/connector/ledger_tasks_test.go index 571b3bcc8..46069f0cd 100644 --- a/internal/connector/ledger_tasks_test.go +++ b/internal/connector/ledger_tasks_test.go @@ -10,8 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -const testRoute = "/work/connector" - // admitOn writes an admitted record on a conversation key. func admitOn(t *testing.T, ledger *Ledger, id int64, key string) { t.Helper() @@ -22,7 +20,7 @@ func admitOn(t *testing.T, ledger *Ledger, id int64, key string) { func launch(t *testing.T, ledger *Ledger, id int64) Launch { t.Helper() - l, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: id, Route: testRoute, Driver: "fake", Deadline: time.Hour}) + l, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: id, Served: []int64{adapterBucketID}, Driver: "fake", Deadline: time.Hour}) require.NoError(t, err) return l } @@ -81,7 +79,7 @@ func TestALaunchHookFailureLeavesNothingWritten(t *testing.T) { admitOn(t, ledger, 1, "recording:1") ledger.SetHooks(Hooks{TaskLaunched: func(context.Context, Tx, Launch) error { return errors.New("outbox refused") }}) - _, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Route: testRoute, Driver: "fake"}) + _, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Served: []int64{adapterBucketID}, Driver: "fake"}) require.Error(t, err) assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State) var tasks, attempts int @@ -90,29 +88,22 @@ func TestALaunchHookFailureLeavesNothingWritten(t *testing.T) { assert.Zero(t, attempts) } -func TestALaunchMustNameTheRecordsRoute(t *testing.T) { - ledger := newTestLedger(t) - admitOn(t, ledger, 1, "recording:1") - _, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: 1, Route: "/somewhere/else", Driver: "fake"}) - assert.ErrorIs(t, err, ErrWorkDirMismatch) - assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State) -} - -// Ledger invariant 2. -func TestOneLiveTaskPerConversationAndPerWorkingDirectory(t *testing.T) { +// Ledger invariant 2. Two conversations run side by side now: nothing holds +// a directory, because there is no per-task directory to hold. +func TestOneLiveTaskPerConversationAndNoMore(t *testing.T) { ledger := newTestLedger(t) ctx := context.Background() admitOn(t, ledger, 1, "recording:1") launch(t, ledger, 1) admitOn(t, ledger, 3, "recording:3") - _, err := ledger.LaunchTask(ctx, LaunchSpec{EventID: 3, Route: testRoute, Driver: "fake"}) - assert.ErrorIs(t, err, ErrNotStartable, "the working directory is busy") + second, err := ledger.LaunchTask(ctx, LaunchSpec{EventID: 3, Served: []int64{adapterBucketID}, Driver: "fake"}) + require.NoError(t, err, "another conversation is another task, in the same directory") + assert.NotZero(t, second.TaskID) - // The database holds it too, whatever the code checks first. - _, err = ledger.db.ExecContext(context.Background(), `INSERT INTO tasks (token_sha256, created_at, conversation_key, work_dir) VALUES ('x', 'now', 'recording:9', ?)`, testRoute) - require.Error(t, err) - _, err = ledger.db.ExecContext(context.Background(), `INSERT INTO tasks (token_sha256, created_at, conversation_key, work_dir) VALUES ('y', 'now', 'recording:1', '/other')`) + // The database holds the conversation rule too, whatever the code checks + // first. + _, err = ledger.db.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at, conversation_key) VALUES ('y', 'now', 'recording:1')`) require.Error(t, err) } @@ -287,7 +278,7 @@ func TestJoinConversationTakesLaterFollowUpsOnlyWhileTheTaskIsLive(t *testing.T) admitOn(t, ledger, 2, "recording:1") assert.Equal(t, StateQueued, getRecord(t, ledger, 2).State) - joined, err := ledger.JoinConversation(ctx, l.TaskID) + joined, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) require.NoError(t, err) assert.Equal(t, []int64{2}, joined) pending, err := ledger.UnexposedEvents(ctx, l.TaskID) @@ -297,7 +288,7 @@ func TestJoinConversationTakesLaterFollowUpsOnlyWhileTheTaskIsLive(t *testing.T) _, err = ledger.EndAttempt(ctx, AttemptEnd{AttemptID: l.AttemptID, Stop: StopFinished}) require.NoError(t, err) admitOn(t, ledger, 3, "recording:1") - joined, err = ledger.JoinConversation(ctx, l.TaskID) + joined, err = ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) require.NoError(t, err) assert.Empty(t, joined) } @@ -328,7 +319,6 @@ func TestLiveAttemptsIncludesLaunching(t *testing.T) { require.Len(t, live, 1) assert.Equal(t, AttemptLaunching, live[0].State) assert.Equal(t, l.AttemptID, live[0].AttemptID) - assert.Equal(t, testRoute, live[0].WorkDir) } func TestAHookFailureRollsTheTransitionBack(t *testing.T) { @@ -410,43 +400,120 @@ func TestAdoptableReplyRule(t *testing.T) { assert.False(t, ok, "a lifecycle message is never adopted") } -// Copilot: a follow-up admitted under another route waits for its own task. -func TestAFollowUpOnAnotherRouteDoesNotJoinTheTask(t *testing.T) { +// A follow-up on the task's conversation joins it. The conversation is the +// whole of the test now: there is no second thing for it to match. +func TestAFollowUpOnTheConversationJoinsTheTask(t *testing.T) { ledger := newTestLedger(t) ctx := context.Background() admitOn(t, ledger, 1, "recording:1") l := launch(t, ledger, 1) + admitOn(t, ledger, 2, "recording:1") + + joined, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) + require.NoError(t, err) + assert.Equal(t, []int64{2}, joined) +} + +// Copilot on #765: the originating record is held to the served set too, not +// only the records that join it. +// +// The ledger launches only what the set it is given covers, and a launch +// naming no set authorizes nothing rather than defaulting open. For the +// dispatcher that is belt and braces — one pass takes a single reading and +// hands the same slice to the startable query and to LaunchSpec, so the +// record is always in it — and it is what makes any other caller fail closed. +// +// It is not a fix for the window between reading connect.json and committing +// the launch: an unserve landing there still starts this one task, and the +// comment here used to say otherwise. +func TestALaunchIsRefusedForAProjectTheSpecDoesNotServe(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + admitOn(t, ledger, 1, "recording:1") + + _, err := ledger.LaunchTask(ctx, LaunchSpec{EventID: 1, Served: []int64{adapterBucketID + 1}, Driver: "fake"}) + assert.ErrorIs(t, err, ErrNotStartable, "another project's served set does not authorize this record") + + _, err = ledger.LaunchTask(ctx, LaunchSpec{EventID: 1, Driver: "fake"}) + assert.ErrorIs(t, err, ErrNotStartable, "and a launch that names no served project authorizes nothing") + + assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State, "nothing was written either time") + + l, err := ledger.LaunchTask(ctx, LaunchSpec{EventID: 1, Served: []int64{adapterBucketID}, Driver: "fake"}) + require.NoError(t, err, "served, and it launches") + assert.NotZero(t, l.TaskID) +} + +// Copilot on #765: a task is authorized against one project, so nothing from +// another project joins it, however the conversation is shared. +// +// A conversation key is the recording's or the Campfire's, never the +// bucket's, so two records on one conversation can sit in two projects — a +// recording moved between them is the ordinary way. The record carries the +// `served` bit admission wrote, which says the project was served *then*; +// joining on that alone would expose an event through a task authorized +// against a different project, and would go on doing it after the operator +// stopped serving the second one. +func TestAFollowUpInAnotherProjectDoesNotJoinTheTask(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + admitOn(t, ledger, 1, "recording:1") + l := launch(t, ledger, 1) + + // Event 2 is on the same conversation and in another project. seenRecord(t, ledger, 2) - v := admittedVerdict(2, 0, "recording:1") - v.Route = "/work/moved" - _, err := ledger.Admission().Commit(ctx, v) + _, err := ledger.ledgerCommitWithBucket(admittedVerdict(2, 0, "recording:1"), adapterBucketID+1) require.NoError(t, err) - joined, err := ledger.JoinConversation(ctx, l.TaskID) + joined, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID, adapterBucketID + 1}) require.NoError(t, err) - assert.Empty(t, joined) + assert.Empty(t, joined, "the task is the originating project's; another project's record is not handed to its worker") + assert.Equal(t, StateQueued, getRecord(t, ledger, 2).State, + "and it waits behind the live task on its conversation for a task of its own, rather than riding along in this one") +} + +// And the served set is read now, not as it was when the record was +// admitted: a project the operator has stopped serving stops feeding the +// live task on its conversation. +func TestAFollowUpInAProjectNoLongerServedDoesNotJoinTheTask(t *testing.T) { + ledger := newTestLedger(t) + ctx := context.Background() + admitOn(t, ledger, 1, "recording:1") + l := launch(t, ledger, 1) + admitOn(t, ledger, 2, "recording:1") + + joined, err := ledger.JoinConversation(ctx, l.TaskID, nil) + require.NoError(t, err) + assert.Empty(t, joined, "no project is served now") + + joined, err = ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID + 5}) + require.NoError(t, err) + assert.Empty(t, joined, "and the task's own project is not among those served") + + joined, err = ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) + require.NoError(t, err) + assert.Equal(t, []int64{2}, joined, "served again, and the follow-up joins") } -// Review r2: work no approved route covers is counted, not silently stuck. -func TestStrandedRecordsCountsWorkNoRouteCovers(t *testing.T) { +// Review r2: work in a project no longer served is counted, not silently +// stuck. +func TestStrandedRecordsCountsWorkInUnservedProjects(t *testing.T) { ledger := newTestLedger(t) ctx := context.Background() admitOn(t, ledger, 1, "recording:1") seenRecord(t, ledger, 2) - moved := admittedVerdict(2, 0, "recording:2") - moved.Route = "/work/moved" - _, err := ledger.Admission().Commit(ctx, moved) + _, err := ledger.ledgerCommitWithBucket(admittedVerdict(2, 0, "recording:2"), adapterBucketID+1) require.NoError(t, err) - stranded, err := ledger.StrandedRecords(ctx, map[int64]string{adapterBucketID: testRoute}, nil) + stranded, err := ledger.StrandedRecords(ctx, []int64{adapterBucketID}, nil) require.NoError(t, err) - assert.Equal(t, 1, stranded, "the record admitted under a route connect.json no longer has") + assert.Equal(t, 1, stranded, "the record in a project connect.json no longer serves") - stranded, err = ledger.StrandedRecords(ctx, map[int64]string{adapterBucketID: testRoute, adapterBucketID + 1: "/work/moved"}, nil) + stranded, err = ledger.StrandedRecords(ctx, []int64{adapterBucketID, adapterBucketID + 1}, nil) require.NoError(t, err) - assert.Equal(t, 1, stranded, "the route must be approved for the record's own project") + assert.Zero(t, stranded, "both projects served") - stranded, err = ledger.StrandedRecords(ctx, map[int64]string{adapterBucketID + 5: testRoute}, []int64{adapterBucketID + 5}) + stranded, err = ledger.StrandedRecords(ctx, []int64{adapterBucketID + 5}, []int64{adapterBucketID + 5}) require.NoError(t, err) assert.Zero(t, stranded, "work in a project this run does not hear is another run's, not stranded") } diff --git a/internal/connector/lifecycle.go b/internal/connector/lifecycle.go index cd45342dc..d554e0d5d 100644 --- a/internal/connector/lifecycle.go +++ b/internal/connector/lifecycle.go @@ -100,11 +100,11 @@ func eventList(ids []int64) string { return "events " + strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] } -// renderHoldingReply is the reply to a mention or assignment in a project that -// has no route. +// renderHoldingReply is the reply to a mention or assignment in a project +// the connector does not serve. func renderHoldingReply(kind MessageKind, eventID int64) string { lines := []string{ - "I can't start on this here yet: this project has no working directory set up for me on the connector's machine, so nothing was run.", + "I can't start on this here yet: this project is not one my connector is set up to work in, so nothing was run.", "Once the project is added to connect.json, a person can run it with: " + redispatchAsk(eventID), "", "Event " + strconv.FormatInt(eventID, 10) + " · " + lifecycleSignature, @@ -496,7 +496,7 @@ ORDER BY o.id`, eventID) } // verdictIntents writes the guard for an admitted request and the holding -// reply for an unrouted one. +// reply for one in a project the connector does not serve. func verdictIntents(ctx context.Context, tx Tx, now time.Time, guardDelay time.Duration, v CommittedVerdict) error { if !v.Acknowledge { // Subscribed and completed are not requests: no guard, no holding diff --git a/internal/connector/lifecycle_test.go b/internal/connector/lifecycle_test.go index 1273d7484..dbde2a06f 100644 --- a/internal/connector/lifecycle_test.go +++ b/internal/connector/lifecycle_test.go @@ -24,7 +24,7 @@ func TestLifecycleTemplatesRenderFromRecordsAlone(t *testing.T) { obAdmit(t, ledger, 1, "recording:10304028989") l := obLaunch(t, ledger, 1) obAdmit(t, ledger, 2, "recording:10304028989") - _, err := ledger.JoinConversation(ctx, l.TaskID) + _, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) require.NoError(t, err) clock.Advance(5 * time.Minute) settlement, err := ledger.EndAttempt(ctx, AttemptEnd{AttemptID: l.AttemptID, Stop: StopDeadline}) diff --git a/internal/connector/operator_invariants_test.go b/internal/connector/operator_invariants_test.go index 179f92482..c1d01e6d8 100644 --- a/internal/connector/operator_invariants_test.go +++ b/internal/connector/operator_invariants_test.go @@ -17,10 +17,7 @@ import ( // The operator decisions and the hold (ledger_hold.go). Each test names the // invariant it holds. -const ( - opRoute = "/work/connector" - opBy = "local:tester" -) +const opBy = "local:tester" // opAdmit writes id seen and commits an admitted verdict on conversation key, // returning the state the ledger wrote. @@ -29,7 +26,6 @@ func opAdmit(t *testing.T, l *Ledger, id int64, key string) RecordState { ctx := context.Background() record := seenRecord(t, l, id) v := admittedVerdict(id, record.Revision, key) - v.Route = opRoute state, err := l.Admission().Commit(ctx, v) require.NoError(t, err) return RecordState(state) @@ -37,7 +33,7 @@ func opAdmit(t *testing.T, l *Ledger, id int64, key string) RecordState { func launchOf(t *testing.T, l *Ledger, id int64) Launch { t.Helper() - launch, err := l.LaunchTask(context.Background(), LaunchSpec{EventID: id, Route: opRoute, Driver: "claude"}) + launch, err := l.LaunchTask(context.Background(), LaunchSpec{EventID: id, Served: []int64{adapterBucketID}, Driver: "claude"}) require.NoError(t, err) return launch } @@ -68,6 +64,34 @@ func unknownOutcome(t *testing.T, l *Ledger, id int64) Launch { return launch } +// Copilot on #765: a redispatch is authorized by the served projects its +// caller read from connect.json, not by the bit admission wrote when it +// decided the record. A reading, not a lock — an unserve landing between +// that read and this write is not caught, and is carded. +// +// Without this the command reported success and admitted the record, and the +// dispatcher then refused to launch it — so the person was told their +// redispatch worked while the record sat stranded, with no holding reply and +// nothing else coming. +func TestRedispatchIsRefusedForAProjectNoLongerServed(t *testing.T) { + l := newTestLedger(t) + ctx := context.Background() + unknownOutcome(t, l, 1) + + _, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID + 1}) + require.ErrorIs(t, err, ErrDecisionRefused, "another project's served set does not authorize this record") + assert.Contains(t, err.Error(), "connect.json serves") + _, err = l.Redispatch(ctx, 1, opBy, nil) + require.ErrorIs(t, err, ErrDecisionRefused, "and serving nothing authorizes nothing") + + assert.Equal(t, StateCompleted, stateOf(t, l, 1), "the record is left where it was, both times") + assert.Zero(t, decisionsFor(t, l, 1), "and nothing was recorded as decided") + + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) + require.NoError(t, err, "served, and the redispatch lands") + assert.True(t, got.Admitted) +} + // Done when: redispatch of completed(unknown) admits the record, supersedes // the task's token and records who authorized it. func TestRedispatchAdmitsAnUnknownOutcome(t *testing.T) { @@ -75,7 +99,7 @@ func TestRedispatchAdmitsAnUnknownOutcome(t *testing.T) { ctx := context.Background() launch := unknownOutcome(t, l, 1) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Admitted) assert.Equal(t, StateAdmitted, got.State) @@ -109,7 +133,7 @@ func TestRedispatchAdmitsAFailedOutcome(t *testing.T) { _, err = l.EndAttempt(ctx, AttemptEnd{AttemptID: launch.AttemptID, Stop: StopFinished}) require.NoError(t, err) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Admitted) assert.Equal(t, OutcomeFailed, got.FromOutcome) @@ -132,7 +156,7 @@ func TestRedispatchOnALiveTaskWaitsForItsEnd(t *testing.T) { _, err = d.Complete(ctx, 1, Completion{Outcome: OutcomeFailed}) require.NoError(t, err) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Pending) assert.False(t, got.Admitted) @@ -143,13 +167,13 @@ func TestRedispatchOnALiveTaskWaitsForItsEnd(t *testing.T) { _, _, err = d.Get(ctx, 2) assert.ErrorIs(t, err, ErrTaskTokenRefused, "the old worker is refused at once") - _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Route: opRoute, Driver: "claude"}) + _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Served: []int64{adapterBucketID}, Driver: "claude"}) assert.ErrorIs(t, err, ErrNotStartable, "no second task while the first is live") startable, err := l.StartableRecords(ctx, 10) require.NoError(t, err) assert.Empty(t, startable) - _, err = l.Redispatch(ctx, 1, opBy) + _, err = l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) assert.ErrorIs(t, err, ErrDecisionRefused, "a second redispatch while the first waits") _, err = l.EndAttempt(ctx, AttemptEnd{AttemptID: launch.AttemptID, Stop: StopLost}) @@ -201,7 +225,7 @@ func TestRedispatchRefusesWhatItMustNotRun(t *testing.T) { arrange(t, l) before := getRecord(t, l, 1) - _, err := l.Redispatch(ctx, 1, opBy) + _, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.ErrorIs(t, err, ErrDecisionRefused) after := getRecord(t, l, 1) assert.Equal(t, before.State, after.State) @@ -223,7 +247,7 @@ func TestRedispatchOfABlockedRecordRerunsItsPrerequisite(t *testing.T) { _, err = l.SetHold(ctx, opBy, HoldByOperator) require.NoError(t, err) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Rerun) assert.True(t, got.Held) @@ -236,13 +260,12 @@ func TestRedispatchOfABlockedRecordRerunsItsPrerequisite(t *testing.T) { require.NoError(t, err) require.True(t, ok) v := admittedVerdict(1, ev.Revision, "recording:1") - v.Route = opRoute written, err := l.Admission().Commit(ctx, v) require.NoError(t, err) assert.Equal(t, admission.StateAdmitted, written, "authorized, so admitted though tagged for review") // Under the hold it is authorized and not launched. - _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Route: opRoute, Driver: "claude"}) + _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Served: []int64{adapterBucketID}, Driver: "claude"}) require.Error(t, err) assert.Contains(t, err.Error(), "held") _, err = l.Release(ctx, opBy) @@ -250,7 +273,8 @@ func TestRedispatchOfABlockedRecordRerunsItsPrerequisite(t *testing.T) { launchOf(t, l, 1) } -// Done when: a held record with its snapshot and route is admitted at once. +// Done when: a held record with its snapshot, in a served project, is +// admitted at once. func TestRedispatchAdmitsAHeldRecord(t *testing.T) { l := newTestLedger(t) ctx := context.Background() @@ -260,7 +284,7 @@ func TestRedispatchAdmitsAHeldRecord(t *testing.T) { assert.Equal(t, 1, res.Held) require.Equal(t, StateHeld, stateOf(t, l, 1)) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Admitted) assert.Equal(t, StateAdmitted, stateOf(t, l, 1)) @@ -276,7 +300,7 @@ func TestRedispatchOfARecordHeldOverAReasonRerunsIt(t *testing.T) { _, err = l.db.ExecContext(context.Background(), `UPDATE events SET reason = 'no_route' WHERE id = 1`) require.NoError(t, err) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.True(t, got.Rerun) record := getRecord(t, l, 1) @@ -382,7 +406,7 @@ func TestInvariant2AHeldLedgerSurvivesRestartUntilRelease(t *testing.T) { startable, err := l.StartableRecords(ctx, 10) require.NoError(t, err) assert.Empty(t, startable, "the dispatcher is offered nothing while the hold stands") - _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Route: opRoute, Driver: "claude"}) + _, err = l.LaunchTask(ctx, LaunchSpec{EventID: 1, Served: []int64{adapterBucketID}, Driver: "claude"}) require.Error(t, err) assert.Equal(t, StateAdmitted, stateOf(t, l, 1), "the refused launch rolled back") @@ -554,7 +578,7 @@ func TestDiscard(t *testing.T) { again, err := l.Discard(ctx, 1, opBy) require.NoError(t, err) assert.True(t, again.Already) - _, err = l.Redispatch(ctx, 1, opBy) + _, err = l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) assert.ErrorIs(t, err, ErrDecisionRefused) }) } @@ -646,7 +670,7 @@ func pendingRedispatch(t *testing.T, l *Ledger) Launch { pulled(t, d, 1) _, err = d.Complete(ctx, 1, Completion{Outcome: OutcomeFailed}) require.NoError(t, err) - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) require.True(t, got.Pending) return launch @@ -692,7 +716,7 @@ func TestInvariant3AHoldWithdrawsAWaitingRedispatch(t *testing.T) { _, err = l.EndAttempt(ctx, AttemptEnd{AttemptID: launch.AttemptID, Stop: StopLost}) require.NoError(t, err) assert.Equal(t, StateCompleted, stateOf(t, l, 1), "the authorization did not survive the hold") - _, err = l.Redispatch(ctx, 1, opBy) + _, err = l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err, "a person can authorize it again") } @@ -728,7 +752,7 @@ func TestASupersededTaskTakesNoFollowUp(t *testing.T) { launch := pendingRedispatch(t, l) require.Equal(t, StateAdmitted, opAdmit(t, l, 2, "recording:9"), "the conversation's task is superseded, so a new event is admitted") - joined, err := l.JoinConversation(ctx, launch.TaskID) + joined, err := l.JoinConversation(ctx, launch.TaskID, []int64{adapterBucketID}) require.NoError(t, err) assert.Empty(t, joined) _, err = l.EndAttempt(ctx, AttemptEnd{AttemptID: launch.AttemptID, Stop: StopLost}) @@ -750,7 +774,7 @@ func TestRedispatchQueuesAHeldRecordBehindALiveConversation(t *testing.T) { require.NoError(t, err) require.Equal(t, StateAdmitted, opAdmit(t, l, 2, "recording:9"), "a new generation's record on the same conversation") - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) assert.Equal(t, StateQueued, got.State) assert.False(t, got.Admitted) @@ -829,7 +853,7 @@ func TestInvariant2ATaskTakesNoFollowUpUnderTheHold(t *testing.T) { require.NoError(t, err) require.Equal(t, StateQueued, opAdmit(t, l, 2, "recording:9"), "a new generation's record on the live conversation") - joined, err := l.JoinConversation(ctx, launch.TaskID) + joined, err := l.JoinConversation(ctx, launch.TaskID, []int64{adapterBucketID}) require.NoError(t, err) assert.Empty(t, joined) assert.Equal(t, StateQueued, stateOf(t, l, 2)) @@ -853,7 +877,7 @@ func TestACompletionNoticeAsksNothingOfAnAuthorizedBlockedRecord(t *testing.T) { require.Len(t, notices, 1) require.Contains(t, notices[0].Body, "redispatch 1") - got, err := l.Redispatch(ctx, 1, opBy) + got, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) require.True(t, got.Rerun) claimed, ok, err := l.claimIntent(ctx) @@ -887,7 +911,7 @@ func TestAnEarlierAuthorizationDoesNotSilenceALaterNotice(t *testing.T) { l.SetHooks(LifecycleHooks(l, LifecycleOptions{})) ctx := context.Background() first := unknownOutcome(t, l, 1) - _, err := l.Redispatch(ctx, 1, opBy) + _, err := l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) second(t, l) @@ -939,7 +963,7 @@ func TestImportDoneClosesAnOutcomeThatWaitedForAPerson(t *testing.T) { var recordedAs string require.NoError(t, l.db.QueryRowContext(ctx, `SELECT to_state FROM decisions WHERE event_id = 2 AND action = 'import'`).Scan(&recordedAs)) assert.Equal(t, string(StateCompleted), recordedAs, "the audit says what happened, not what would have") - _, err = l.Redispatch(ctx, 1, opBy) + _, err = l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) assert.ErrorIs(t, err, ErrDecisionRefused) claimed, ok, err := l.claimIntent(ctx) require.NoError(t, err) @@ -963,7 +987,7 @@ func TestARedispatchOntoBlockedIsAuthorizedForThatBlock(t *testing.T) { calls := 0 l.now = func() time.Time { calls++; return base.Add(time.Duration(calls) * time.Second) } // each stamp later than the last - _, err = l.Redispatch(ctx, 1, opBy) + _, err = l.Redispatch(ctx, 1, opBy, []int64{adapterBucketID}) require.NoError(t, err) ids, err := l.AuthorizedBlocked(ctx, 10) require.NoError(t, err) diff --git a/internal/connector/outbox.go b/internal/connector/outbox.go index 453580f17..a9982eae8 100644 --- a/internal/connector/outbox.go +++ b/internal/connector/outbox.go @@ -291,7 +291,7 @@ const ( IntentGuardAck IntentKind = "guard_ack" // IntentHoldingReply answers a request the connector is not going to // start work on until a person changes something: a mention or - // assignment in a project with no route. One per event. + // assignment in a project the connector does not serve. One per event. IntentHoldingReply IntentKind = "holding_reply" // IntentStillRunning is one still-running notice. One per attempt and // occurrence. @@ -393,7 +393,7 @@ func holdingKey(eventID int64) string { } // legacyRefusedStartKey is the key a connector that made worktrees gave the -// holding reply for a record whose route could take no worktree. Nothing +// holding reply for a record whose directory could take no worktree. Nothing // writes one now — the refusal that called for it went with worktrees — but // an upgraded ledger still holds the ones that build wrote, pending or sent, // over records still blocked legacyReasonRouteUnusable. They are read as they diff --git a/internal/connector/outbox_fakes_test.go b/internal/connector/outbox_fakes_test.go index 31a64069f..42cc5abd2 100644 --- a/internal/connector/outbox_fakes_test.go +++ b/internal/connector/outbox_fakes_test.go @@ -17,7 +17,6 @@ import ( // collide with the dispatcher's own test helpers. const ( - obRoute = "/work/connector" obEventRecording = int64(10304028972) // testEvent's recording obReplyRecording = int64(10304028989) // admittedVerdict's reply destination obCampfire = int64(10304030000) @@ -61,18 +60,18 @@ func obAdmit(t *testing.T, ledger *Ledger, id int64, key string) { require.NoError(t, err) } -// obNoRouteVerdict is a mention in a project with no route. +// obNoRouteVerdict is a mention in a project the connector does not serve. func obNoRouteVerdict(id, revision int64, reply admission.ReplyDestination) admission.Verdict { v := admittedVerdict(id, revision, "recording:10304028989") v.State, v.Reason = admission.StateBlocked, admission.ReasonNoRoute - v.Routed, v.Route, v.Class, v.Snapshot = false, "", "", nil + v.Served, v.Class, v.Snapshot = false, "", nil v.Reply = &reply return v } func obLaunch(t *testing.T, ledger *Ledger, id int64) Launch { t.Helper() - l, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: id, Route: obRoute, Driver: "fake", Deadline: time.Hour}) + l, err := ledger.LaunchTask(context.Background(), LaunchSpec{EventID: id, Served: []int64{adapterBucketID}, Driver: "fake", Deadline: time.Hour}) require.NoError(t, err) return l } diff --git a/internal/connector/outbox_invariants_test.go b/internal/connector/outbox_invariants_test.go index e8d8c7230..88d8cd9f2 100644 --- a/internal/connector/outbox_invariants_test.go +++ b/internal/connector/outbox_invariants_test.go @@ -450,7 +450,7 @@ func TestOutboxAFiredGuardIsReportedToTheWorker(t *testing.T) { require.NoError(t, obOutbox(t, ledger, basecamp).Flush(ctx)) require.Equal(t, 1, basecamp.postCount(), "only the follow-up's guard: the first was canceled") - joined, err := ledger.JoinConversation(ctx, l.TaskID) + joined, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) require.NoError(t, err) require.Equal(t, []int64{2}, joined) instruction, _, err := d.Get(ctx, 2) diff --git a/internal/connector/outbox_run.go b/internal/connector/outbox_run.go index 529784f5f..c1d6ea5be 100644 --- a/internal/connector/outbox_run.go +++ b/internal/connector/outbox_run.go @@ -418,7 +418,7 @@ func (l *Ledger) claimIntent(ctx context.Context, skip ...int64) (Intent, bool, // blocked on that reason. Which reason is the key's to say // (holdingReplyReason): what is written now answers no_route, and // an upgraded ledger can still hold one an older build wrote for - // a route no worktree could be made in. + // a directory no worktree could be made in. var stillBlocked bool switch err := tx.QueryRowContext(ctx, `SELECT state = 'blocked' AND reason = ? FROM events WHERE id = ?`, holdingReplyReason(in), in.EventID).Scan(&stillBlocked); { diff --git a/internal/connector/policy.go b/internal/connector/policy.go index b4955cc14..912636598 100644 --- a/internal/connector/policy.go +++ b/internal/connector/policy.go @@ -2,39 +2,44 @@ package connector import ( "context" - "errors" - "io/fs" - "os" - "path/filepath" "slices" "strings" "github.com/basecamp/basecamp-cli/internal/connector/driver" ) -// Policy is the connector's v1 permission policy: work in the working -// directory and the agent's Basecamp MCP tools are allowed, and the rest is -// refused without asking anyone. It is policy, not containment: the worker -// runs with the operator's ambient authority, as it does today, and a -// sandbox launcher is what contains it. -type Policy struct { - WorkDir string -} +// Policy is the connector's v1 permission policy: reading, searching, +// planning, editing and the agent's Basecamp MCP tools are allowed, and the +// rest is refused without asking anyone. +// +// It bounds no directory, and that is a deliberate gap rather than an +// oversight. Until a project was routed to one, the policy refused any edit +// resolving outside the record's working directory; with the routes gone the +// only directory left is wherever the operator happened to start the +// connector, and a boundary that moves with that looks like a guarantee and +// behaves like an accident — worse to reason about than none. So the bound +// went with the route rather than being repointed. +// +// It was policy, not containment, even when it was there: the worker runs +// with the operator's ambient authority, so anything escaping the tool layer +// — a shell, a path the check could not see, a prompt injection that finds +// one — was already unstopped. What contains a worker is the agent's own +// sandbox (Codex's) and the sandbox launcher being built separately. Until +// that lands, a worker edits wherever the account can. +type Policy struct{} var _ driver.PermissionPolicy = Policy{} -// DefaultPolicy is the v1 policy for a working directory. -func DefaultPolicy(workDir string) Policy { return Policy{WorkDir: workDir} } +// DefaultPolicy is the v1 policy. +func DefaultPolicy() Policy { return Policy{} } -// policyAllowedKinds are what a worker does without asking, besides edits -// inside the working directory. +// policyAllowedKinds are what a worker does without asking, besides edits. var policyAllowedKinds = []driver.ToolKind{driver.ToolRead, driver.ToolSearch, driver.ToolThink} // Rules implements driver.PermissionPolicy. func (p Policy) Rules() driver.PermissionRules { return driver.PermissionRules{ - Mode: driver.ModeEditsInWorkDir, - WorkDir: p.WorkDir, + Mode: driver.ModeEdits, AllowKinds: slices.Clone(policyAllowedKinds), AllowMCPServers: []string{MCPServerName}, } @@ -45,110 +50,8 @@ func (p Policy) Decide(_ context.Context, req driver.PermissionRequest) driver.P if strings.HasPrefix(req.Tool, "mcp__"+MCPServerName+"__") { return driver.PermissionDecision{Allow: true} } - switch { - case req.Kind == driver.ToolThink: - // The only allowed kind that touches no file. + if req.Kind == driver.ToolEdit || slices.Contains(policyAllowedKinds, req.Kind) { return driver.PermissionDecision{Allow: true} - case slices.Contains(policyAllowedKinds, req.Kind), req.Kind == driver.ToolEdit: - // A call on the filesystem that names no path is one the policy - // cannot place inside the working directory, so it is refused. - return driver.PermissionDecision{Allow: len(req.Locations) > 0 && p.inside(req.Locations)} } return driver.PermissionDecision{Allow: false} } - -// maxLinkHops bounds how many links one path may be resolved through, as the -// kernel's ELOOP does. A loop of links names no file, and a path this cannot -// resolve is refused rather than guessed at. -const maxLinkHops = 32 - -// resolveExisting resolves the symlinks in the longest existing prefix of an -// absolute path and appends the rest, which does not exist yet and so cannot -// be a link. -// -// It walks the components itself rather than leaning on EvalSymlinks alone, -// because EvalSymlinks answers ENOENT to two opposite questions: a component -// that is not there, and a symlink that IS there and points at something -// that is not. Treating the second as a name yet to be created approved a -// write to /link when the link pointed at /elsewhere/missing — -// which is where the write would land, creating a file outside the working -// directory (Copilot on #738). A link that exists is followed to wherever it -// points, existing or not, and a link that cannot be read resolves to -// nothing. -func resolveExisting(path string) (string, bool) { - return resolveHops(path, maxLinkHops) -} - -func resolveHops(path string, hops int) (string, bool) { - if hops <= 0 || !filepath.IsAbs(path) { - return "", false - } - rest := "" - for current := filepath.Clean(path); ; { - info, err := os.Lstat(current) - switch { - case err == nil && info.Mode()&fs.ModeSymlink != 0: - // A link that is there. Where it points is where a write to this - // path lands, whether or not anything is there yet. - target, err := os.Readlink(current) - if err != nil { - return "", false - } - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(current), target) - } - resolved, ok := resolveHops(target, hops-1) - if !ok { - return "", false - } - return filepath.Join(resolved, rest), true - case err == nil: - // Something that is there and is not a link; the links above it - // are what is left to resolve. - resolved, err := filepath.EvalSymlinks(current) - if err != nil { - return "", false - } - return filepath.Join(resolved, rest), true - case errors.Is(err, fs.ErrNotExist): - parent := filepath.Dir(current) - if parent == current { - return "", false - } - rest = filepath.Join(filepath.Base(current), rest) - current = parent - default: - return "", false - } - } -} - -// inside reports whether every location is within the working directory, as -// the filesystem resolves it: a symlink inside the directory that points out -// of it is outside. -func (p Policy) inside(locations []string) bool { - root, err := filepath.EvalSymlinks(filepath.Clean(p.WorkDir)) - if err != nil { - return false - } - for _, loc := range locations { - if strings.TrimSpace(loc) == "" { - // A location that names nothing resolves to the working directory - // itself, which would let a request that named no path pass as one - // inside it. - return false - } - if !filepath.IsAbs(loc) { - loc = filepath.Join(p.WorkDir, loc) - } - resolved, ok := resolveExisting(filepath.Clean(loc)) - if !ok { - return false - } - rel, err := filepath.Rel(root, resolved) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return false - } - } - return true -} diff --git a/internal/connector/policy_test.go b/internal/connector/policy_test.go index 7bcc2980a..6a2905431 100644 --- a/internal/connector/policy_test.go +++ b/internal/connector/policy_test.go @@ -3,8 +3,6 @@ package connector import ( "context" "math" - "os" - "path/filepath" "strings" "testing" @@ -15,31 +13,46 @@ import ( "github.com/basecamp/basecamp-cli/internal/connector/driver" ) -func TestThePolicyAllowsWorkInTheDirectoryAndTheAgentsToolsOnly(t *testing.T) { - root := filepath.Join(t.TempDir(), "repo") - require.NoError(t, os.Mkdir(root, 0o700)) - p := DefaultPolicy(root) +func TestThePolicyAllowsEditsAndTheAgentsToolsOnly(t *testing.T) { + p := DefaultPolicy() ctx := context.Background() allow := func(req driver.PermissionRequest) bool { return p.Decide(ctx, req).Allow } assert.True(t, allow(driver.PermissionRequest{Tool: "mcp__basecamp__basecamp_connect", Kind: driver.ToolOther})) - assert.True(t, allow(driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{filepath.Join(root, "a.go")}})) + assert.True(t, allow(driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{"/work/repo/a.go"}})) assert.True(t, allow(driver.PermissionRequest{Kind: driver.ToolRead, Locations: []string{"lib/b.go"}})) + assert.True(t, allow(driver.PermissionRequest{Kind: driver.ToolThink})) - assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{root + "/../other/a.go"}})) - assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{root + "sitory/a.go"}}), "a sibling sharing a prefix is outside") - assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolEdit}), "an edit that names no path is not known to be inside") - assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolExecute, Locations: []string{root}})) + assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolExecute, Locations: []string{"/work/repo"}})) assert.False(t, allow(driver.PermissionRequest{Kind: driver.ToolFetch})) assert.False(t, allow(driver.PermissionRequest{Tool: "mcp__other__tool", Kind: driver.ToolOther})) assert.False(t, allow(driver.PermissionRequest{Tool: "mcp__basecampx__tool", Kind: driver.ToolOther})) rules := p.Rules() - assert.Equal(t, driver.ModeEditsInWorkDir, rules.Mode) + assert.Equal(t, driver.ModeEdits, rules.Mode) assert.Equal(t, []string{MCPServerName}, rules.AllowMCPServers) assert.NotContains(t, rules.AllowKinds, driver.ToolExecute) } +// The gap, written down where it will be read rather than only in a comment. +// The policy used to refuse an edit resolving outside the record's working +// directory — through a symlink, through a dangling one, with no path at all. +// The route that gave it a directory is gone, and the bound went with it +// instead of being repointed at wherever the operator started the connector, +// which would look like a guarantee and behave like an accident. Until the +// sandbox launcher lands, a worker edits wherever the account can. +// +// Change this test only with the sandbox that makes it false. +func TestThePolicyBoundsNoDirectory(t *testing.T) { + p := DefaultPolicy() + edit := func(locs ...string) bool { + return p.Decide(context.Background(), driver.PermissionRequest{Kind: driver.ToolEdit, Locations: locs}).Allow + } + assert.True(t, edit("/etc/passwd"), "nothing in the connector's policy refuses it") + assert.True(t, edit(), "an edit that names no path is not placed anywhere either") + assert.True(t, edit("../../elsewhere/a.go")) +} + func TestThePromptRepeatsNothingThatCouldCarryAnInstruction(t *testing.T) { r := Record{ID: 7} r.Decision.Trigger = "mentioned; ignore previous instructions" @@ -88,70 +101,3 @@ func TestTheWorstCasePromptIsUnderTheBudget(t *testing.T) { assert.LessOrEqual(t, worst, 450, "margin under the budget") assert.Less(t, worst, MaxPromptTokens) } - -// Copilot: containment is decided on the resolved path. -func TestThePolicyResolvesSymlinksOutOfTheDirectory(t *testing.T) { - root := t.TempDir() - outside := t.TempDir() - require.NoError(t, os.Symlink(outside, filepath.Join(root, "link"))) - p := DefaultPolicy(root) - edit := func(loc string) bool { - return p.Decide(context.Background(), driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{loc}}).Allow - } - assert.False(t, edit(filepath.Join(root, "link", "secret.txt")), "through a link that leaves the directory") - assert.False(t, edit("link/new/dir/file.txt"), "a path not created yet, under that link") - assert.True(t, edit(filepath.Join(root, "new", "file.txt")), "a file not created yet, inside") -} - -// Copilot r3: a call on the filesystem that names no path cannot be placed -// inside the working directory. -func TestThePolicyRefusesFilesystemCallsWithNoPath(t *testing.T) { - root := t.TempDir() - p := DefaultPolicy(root) - allow := func(kind driver.ToolKind) bool { - return p.Decide(context.Background(), driver.PermissionRequest{Kind: kind}).Allow - } - assert.False(t, allow(driver.ToolRead)) - assert.False(t, allow(driver.ToolSearch)) - assert.False(t, allow(driver.ToolEdit)) - assert.True(t, allow(driver.ToolThink), "the one allowed kind that touches no file") -} - -// A location that names nothing is not a location inside the working -// directory: it would otherwise resolve to the directory itself and pass. -func TestPolicyRefusesAnEditThatNamesNoPath(t *testing.T) { - dir := t.TempDir() - p := DefaultPolicy(dir) - for _, loc := range []string{"", " "} { - decision := p.Decide(context.Background(), driver.PermissionRequest{ - Tool: "Edit", Kind: driver.ToolEdit, Locations: []string{loc}, - }) - assert.False(t, decision.Allow, "an edit whose location is %q", loc) - } - allowed := p.Decide(context.Background(), driver.PermissionRequest{ - Tool: "Edit", Kind: driver.ToolEdit, Locations: []string{filepath.Join(dir, "file.go")}, - }) - assert.True(t, allowed.Allow) -} - -// Copilot on #738: a symlink that exists and points at something that does -// not is not a name yet to be created. Opening it creates the file it points -// at, which is wherever the link says — so the policy resolves the link -// rather than reading the kernel's ENOENT as "nothing here yet". -func TestADanglingLinkIsResolvedToWhereItPoints(t *testing.T) { - root := t.TempDir() - outside := filepath.Join(t.TempDir(), "missing") - require.NoError(t, os.Symlink(outside, filepath.Join(root, "dangling"))) - require.NoError(t, os.Symlink(filepath.Join(root, "inside-missing"), filepath.Join(root, "inward"))) - require.NoError(t, os.Symlink(filepath.Join(root, "loop"), filepath.Join(root, "loop"))) - p := DefaultPolicy(root) - edit := func(loc string) bool { - return p.Decide(context.Background(), driver.PermissionRequest{Kind: driver.ToolEdit, Locations: []string{loc}}).Allow - } - - assert.False(t, edit(filepath.Join(root, "dangling")), "writing it creates a file outside the working directory") - assert.False(t, edit(filepath.Join(root, "dangling", "under.txt")), "and so does writing under it") - assert.False(t, edit(filepath.Join(root, "loop")), "a path that resolves to nothing is refused, not guessed at") - assert.True(t, edit(filepath.Join(root, "inward")), "a link to a name inside the directory is still inside") - assert.True(t, edit(filepath.Join(root, "new", "file.txt")), "and a file not created yet, inside, is unaffected") -} diff --git a/internal/connector/recovery_acp_test.go b/internal/connector/recovery_acp_test.go index e85fa0fca..16188e529 100644 --- a/internal/connector/recovery_acp_test.go +++ b/internal/connector/recovery_acp_test.go @@ -54,7 +54,7 @@ func init() { // the fake worker has passed the same handshake a real one does. func fakeACPAdapter(w *fakeWorker) int { adapter := acp.ClaudeAgentACP - askMode := adapter.Modes[driver.ModeEditsInWorkDir] + askMode := adapter.Modes[driver.ModeEdits] const sessionID = "recovery-harness" out := bufio.NewWriter(os.Stdout) diff --git a/internal/connector/recovery_connector_test.go b/internal/connector/recovery_connector_test.go index 2b7c30d27..f96883b2e 100644 --- a/internal/connector/recovery_connector_test.go +++ b/internal/connector/recovery_connector_test.go @@ -262,18 +262,19 @@ func runHarnessConnector(dir string) error { } intake.repairSweep = 50 * time.Millisecond - // Two routed projects, each its own working directory, so a test can show - // the dispatcher still runs work in one while the other's is held. + // Two served projects, so a test can show the dispatcher still runs work + // in one while the other's attempt is held. Both run in the connector's + // own directory, as everything does now. work := filepath.Join(dir, "work") - routes := map[int64]admission.Route{ - harnessBucket: {Path: work, Class: "internal"}, - harnessOtherBucket: {Path: filepath.Join(dir, "work-other"), Class: "internal"}, + served := map[int64]admission.Project{ + harnessBucket: {Class: "internal"}, + harnessOtherBucket: {Class: "internal"}, } reads := storeReads{dir: dir, gate: sc.ReadGate, kill: kill} admitter, err := admission.NewAdmitter(admission.Policy{ AgentID: harnessAgent, Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: harnessOperator}, - Projects: routes, + Projects: served, }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}) if err != nil { return err @@ -299,7 +300,8 @@ func runHarnessConnector(dir string) error { worker := &failingSpawns{Driver: working, broken: d.New(filepath.Join(dir, "no-such-agent")), failures: failures} dispatcher, err := NewDispatcher(DispatcherOptions{ Ledger: ledger, Driver: worker, - Routes: func() map[int64]admission.Route { return routes }, + Served: func() (map[int64]admission.Project, error) { return served, nil }, + WorkDir: work, Concurrency: 2, Deadline: time.Hour, MCP: mcp, diff --git a/internal/connector/recovery_dispatch_test.go b/internal/connector/recovery_dispatch_test.go index 2970ea7bc..9405efda4 100644 --- a/internal/connector/recovery_dispatch_test.go +++ b/internal/connector/recovery_dispatch_test.go @@ -325,7 +325,7 @@ func TestRecoveryPostsUnknownAndRedispatchRunsItAgain(t *testing.T) { assert.Equal(t, int64(5001), notices[0].RecordingID, "on the recording that asked") l := h.ledger() - got, err := l.Redispatch(context.Background(), 101, "operator") + got, err := l.Redispatch(context.Background(), 101, "operator", []int64{adapterBucketID}) require.NoError(t, err) assert.False(t, got.Held) h.run(harnessRun{}) @@ -553,9 +553,13 @@ func TestRecoveryTheDispatchPromptIsUnderBudget(t *testing.T) { } // An attempt whose worker the connector cannot identify is held, not settled: -// it stays live in the ledger, its conversation and its working directory -// stay its own, and no restart runs anything for it — while the dispatcher -// goes on running work that does not need them. +// it stays live in the ledger, its conversation stays its own, its slot stays +// taken, and no restart runs anything for it — while the dispatcher goes on +// running everything else. +// +// What it no longer holds is a directory. There is none to hold: every worker +// runs where the connector was started, so another conversation in the same +// project starts beside the held attempt rather than waiting behind it. // // The crash here is at launching, just after the attempt is written and // before the driver is asked for anything. No process exists, but the ledger @@ -578,21 +582,22 @@ func TestRecoveryHoldsAnAttemptItCannotIdentify(t *testing.T) { // other project has been dispatched and finished: proof that its // recovery returned and its dispatcher went on, not merely that it // logged a decision. - // A further event in the held project, on another recording, needs the - // held directory: it waits. + // A further event in the held project, on another recording, is + // another conversation. It runs: the held attempt takes a slot, not + // a directory. h.publish(feedEntry{Event: todoEvent(104, 5004)}) + h.run(harnessRun{Until: "state:104=completed", RequireLog: "cannot be identified"}) + assert.Equal(t, string(OutcomeSucceeded), outcomeOf(t, l, 104)) for i, other := range []int64{105, 106} { h.publish(feedEntry{Event: otherTodoEvent(other, 6001+int64(i))}) h.run(harnessRun{Until: "state:" + strconv.FormatInt(other, 10) + "=completed", RequireLog: "cannot be identified"}) assert.Equal(t, string(OutcomeSucceeded), outcomeOf(t, l, other)) } - assert.Equal(t, StateAdmitted, stateOf(t, l, 104), "nothing new starts in the held directory") - assert.Equal(t, 0, h.handed(104)) assert.Equal(t, StateDispatched, stateOf(t, l, 101), "the record stays live: nobody may act on it but a person") assert.Equal(t, 0, h.handed(101), "no worker was ever given the event") attempts = harnessAttempts(t, l) - require.Len(t, attempts, 3, "the held attempt, and one for each event in the other project") + require.Len(t, attempts, 4, "the held attempt, and one for each event that ran beside it") assert.Equal(t, string(AttemptLaunching), attempts[0].State) assert.Empty(t, attempts[0].StopReason) assert.Empty(t, h.notices(101), "an attempt that is still live has no completion to post") @@ -668,11 +673,13 @@ func TestRecoveryTheGuardAcknowledgementIsPostedAtMostOnce(t *testing.T) { } // One owner, one release point: a worker's tree that outlives it keeps its -// attempt live, its record non-terminal and its working directory unreleased, -// through any number of restarts, because recovery holds an attempt whose -// worker it cannot verify rather than settling around it — while it goes on -// running work that does not need that directory. Once the tree is gone, the -// next restart settles the attempt and releases the directory. +// attempt live and its record non-terminal through any number of restarts, +// because recovery holds an attempt whose worker it cannot verify rather than +// settling around it — while it goes on running everything else. Once the +// tree is gone, the next restart settles the attempt. +// +// What is held is the attempt's slot and its conversation, not a directory: +// another conversation in the same project runs beside it. func TestRecoveryAWorkersSurvivingTreeKeepsItsAttempt(t *testing.T) { forEachDriver(t, func(t *testing.T, d harnessDriver) { raceSubset(t, false) @@ -697,12 +704,14 @@ func TestRecoveryAWorkersSurvivingTreeKeepsItsAttempt(t *testing.T) { drivertest.RequireGroupHeld(t, worker) h.publish(feedEntry{Event: todoEvent(104, 5004)}) + h.run(harnessRun{Until: "state:104=completed", + RequireLog: "could not verify whether a previous worker still runs"}) + assert.Equal(t, string(OutcomeSucceeded), outcomeOf(t, l, 104), "another conversation runs beside the held attempt") for i, other := range []int64{105, 106} { h.publish(feedEntry{Event: otherTodoEvent(other, 6001+int64(i))}) h.run(harnessRun{Until: "state:" + strconv.FormatInt(other, 10) + "=completed", RequireLog: "could not verify whether a previous worker still runs"}) - assert.Equal(t, string(OutcomeSucceeded), outcomeOf(t, l, other), "work that does not need the held directory still runs") - assert.Equal(t, StateAdmitted, stateOf(t, l, 104), "nothing new starts in the held directory") + assert.Equal(t, string(OutcomeSucceeded), outcomeOf(t, l, other), "work in the other project still runs") assert.Equal(t, string(AttemptRunning), attemptState(t, l, attempts[0].id), "the attempt stays live while its tree runs") assert.Equal(t, StateDispatched, stateOf(t, l, 101), "the record is not made terminal") @@ -722,7 +731,6 @@ func TestRecoveryAWorkersSurvivingTreeKeepsItsAttempt(t *testing.T) { assert.Equal(t, string(OutcomeUnknown), outcomeOf(t, l, 101)) assert.Len(t, h.notices(101), 1) assert.Equal(t, 1, h.handed(101), "and never run again") - assert.Equal(t, 1, h.handed(104), "the task settled, the waiting event runs") h.assertNoWorkerOutlivedItsRecord() }) } diff --git a/internal/connector/recovery_fakes_test.go b/internal/connector/recovery_fakes_test.go index 0a5bc243d..0af1dcbdb 100644 --- a/internal/connector/recovery_fakes_test.go +++ b/internal/connector/recovery_fakes_test.go @@ -53,7 +53,7 @@ func todoEvent(id, recording int64) eventfeed.Event { } } -// otherTodoEvent is todoEvent in the second routed project. +// otherTodoEvent is todoEvent in the second served project. func otherTodoEvent(id, recording int64) eventfeed.Event { e := todoEvent(id, recording) e.BucketID = harnessOtherBucket diff --git a/internal/connector/recovery_harness_test.go b/internal/connector/recovery_harness_test.go index 58cfc9785..11141d1a3 100644 --- a/internal/connector/recovery_harness_test.go +++ b/internal/connector/recovery_harness_test.go @@ -281,13 +281,13 @@ func runFakeAgent(name string) int { return d.Agent(w) } -// Scenario constants: one account, one agent, one operator, one routed project. +// Scenario constants: one account, one agent, one operator, one served project. const ( harnessAccount = "2914079" harnessAgent = adapterAgentID harnessOperator = adapterOperatorID harnessBucket = adapterBucketID - // harnessOtherBucket is a second routed project with its own directory. + // harnessOtherBucket is a second served project. harnessOtherBucket = int64(48929974) harnessOrigin = "https://3.basecampapi.com" harnessNamespace = "basecamp-connect-recovery" @@ -1116,7 +1116,7 @@ func runSecretScan(dirs []string) int { return 0 } -// workDir is the first routed project's working directory. +// workDir is the directory the connector runs in, and so every worker. func (h *harness) workDir() string { return filepath.Join(h.dir, "work") } // children is every process a fake worker started of its own, with the time diff --git a/internal/connector/retraction_test.go b/internal/connector/retraction_test.go index d7a9ee363..eb63b40e9 100644 --- a/internal/connector/retraction_test.go +++ b/internal/connector/retraction_test.go @@ -57,7 +57,7 @@ func TestPostedAskIsRetractedWhenItIsAnswered(t *testing.T) { require.Equal(t, IntentSent, holding.State) clock.Advance(12 * time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) in := obRetraction(t, ledger, holding, 1) @@ -96,7 +96,7 @@ func TestPostedAskIsRetractedWhenItIsAnswered(t *testing.T) { require.Contains(t, completion.Body, "Needs a person: basecamp connect redispatch 1") clock.Advance(3 * time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) in := obRetraction(t, ledger, completion, 1) @@ -143,7 +143,7 @@ func TestPostedAskIsRetractedWhenItIsAnswered(t *testing.T) { holding := obIntent(t, ledger, holdingKey(1)) require.Equal(t, IntentSent, holding.State) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) in := obRetraction(t, ledger, holding, 1) assert.Equal(t, MessageChatLine, in.Destination.Kind) @@ -161,7 +161,7 @@ func TestARetractionSpeaksOnlyForItsOwnEvent(t *testing.T) { obAdmit(t, ledger, 1, "recording:10304028989") l := obLaunch(t, ledger, 1) obAdmit(t, ledger, 2, "recording:10304028989") - joined, err := ledger.JoinConversation(ctx, l.TaskID) + joined, err := ledger.JoinConversation(ctx, l.TaskID, []int64{adapterBucketID}) require.NoError(t, err) require.Equal(t, []int64{2}, joined) exposed, err := ledger.ExposeEvent(ctx, l.AttemptID, 2) @@ -182,7 +182,7 @@ func TestARetractionSpeaksOnlyForItsOwnEvent(t *testing.T) { // One of the two is decided. The other's ask is untouched, and the // retraction does not speak for it. clock.Advance(time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) first := obRetraction(t, ledger, completion, 1) @@ -237,7 +237,7 @@ func TestARetractionWaitsWhileTheAskIsStillOpen(t *testing.T) { require.Equal(t, IntentSent, holding.State) clock.Advance(12 * time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) require.Equal(t, StateBlocked, getRecord(t, ledger, 1).State, "authorized, and still waiting on the route") @@ -314,7 +314,7 @@ func TestAskStillOpenReadsWhatTheRecordIsWaitingFor(t *testing.T) { require.NoError(t, err) assert.True(t, open(t, ctx, ledger, completion, 1), "unknown, and nobody has decided it") - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) assert.False(t, open(t, ctx, ledger, completion, 1), "redispatched: it is going to run") @@ -407,7 +407,7 @@ func TestOnlyAnAskIsRetracted(t *testing.T) { require.Equal(t, IntentSent, completion.State) clock.Advance(time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) retractions := obRetractions(t, ledger) @@ -429,7 +429,7 @@ func TestAnAskThatWasNeverSentIsNotRetracted(t *testing.T) { completion := obIntent(t, ledger, completionKey(l.AttemptID)) require.Equal(t, IntentPending, completion.State) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) assert.Empty(t, obRetractions(t, ledger)) @@ -454,7 +454,7 @@ func TestANoticeThatNeverAskedIsNotRetracted(t *testing.T) { clock.Advance(5 * time.Minute) _, err := ledger.EndAttempt(ctx, AttemptEnd{AttemptID: first.AttemptID, Stop: StopDeadline}) require.NoError(t, err) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) basecamp := newFakeBasecamp(clock.Now) ob := obOutbox(t, ledger, basecamp) @@ -482,7 +482,7 @@ func TestRetractionWaitsForTheNoticeItAnswers(t *testing.T) { t.Run("sent after the decision", func(t *testing.T) { ctx := context.Background() ledger, clock, ob, basecamp, holding := obSendingHoldingReply(t) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) in := obRetraction(t, ledger, holding, 1) require.Equal(t, IntentPending, in.State) @@ -510,7 +510,7 @@ func TestRetractionWaitsForTheNoticeItAnswers(t *testing.T) { t.Run("left indeterminate after the decision: it waits for the person", func(t *testing.T) { ctx := context.Background() ledger, clock, ob, basecamp, holding := obSendingHoldingReply(t) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) _, err = ledger.settleReconciled(ctx, holding.ID, 0, obUnreachableNote) @@ -535,7 +535,7 @@ func TestAPersonsResolutionOfTheNoticeDecidesItsRetraction(t *testing.T) { t.Run("resolved sent: the ask is on the card, so it is answered", func(t *testing.T) { ctx := context.Background() ledger, clock, ob, basecamp, holding := obSendingHoldingReply(t) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) obRoutedNow(t, ledger, 1) @@ -558,7 +558,7 @@ func TestAPersonsResolutionOfTheNoticeDecidesItsRetraction(t *testing.T) { t.Run("resolved sent after the outbox ticked: still answered", func(t *testing.T) { ctx := context.Background() ledger, clock, ob, basecamp, holding := obSendingHoldingReply(t) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) obRoutedNow(t, ledger, 1) @@ -583,7 +583,7 @@ func TestAPersonsResolutionOfTheNoticeDecidesItsRetraction(t *testing.T) { t.Run("abandoned: nothing was said, so nothing is answered", func(t *testing.T) { ctx := context.Background() ledger, clock, ob, basecamp, holding := obSendingHoldingReply(t) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) obRoutedNow(t, ledger, 1) @@ -632,7 +632,7 @@ func TestAnAskIsRetractedOnce(t *testing.T) { holding := obIntent(t, ledger, holdingKey(1)) clock.Advance(12 * time.Minute) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) obRoutedNow(t, ledger, 1) // The retraction goes out, and names the command in its own words. A @@ -650,7 +650,7 @@ func TestAnAskIsRetractedOnce(t *testing.T) { _, err = ledger.EndAttempt(ctx, AttemptEnd{AttemptID: l.AttemptID, Stop: StopDeadline}) require.NoError(t, err) require.Equal(t, IntentPending, obIntent(t, ledger, completionKey(l.AttemptID)).State) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) retractions := obRetractions(t, ledger) @@ -865,9 +865,9 @@ VALUES (7, 'holding_reply:event:1', 'holding_reply', 'sent', 1, 48699913 // A person routes the project and redispatches. Deciding the record // again while its prerequisite runs answers nothing twice. - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) - _, err = ledger.Redispatch(ctx, 1, "jorge") + _, err = ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) answered := map[int64]Intent{} @@ -957,7 +957,7 @@ func TestALegacyRefusedStartReplyIsReadAsTheReasonItWasWrittenFor(t *testing.T) require.Equal(t, IntentSent, sent.State) clock.Advance(12 * time.Minute) - _, err := ledger.Redispatch(ctx, 1, "jorge") + _, err := ledger.Redispatch(ctx, 1, "jorge", []int64{adapterBucketID}) require.NoError(t, err) require.Equal(t, StateBlocked, getRecord(t, ledger, 1).State, "authorized, and still blocked on route_unusable") diff --git a/internal/connector/setup/apply.go b/internal/connector/setup/apply.go index 1c8c2b89f..281abb26d 100644 --- a/internal/connector/setup/apply.go +++ b/internal/connector/setup/apply.go @@ -3,8 +3,6 @@ package setup import ( "errors" "fmt" - "os" - "path/filepath" "slices" "strings" "time" @@ -13,23 +11,23 @@ import ( ) // Changes is what one setup run asks to change. A zero field leaves the -// file's value alone, so setup can be run again to add a route without -// restating everything else. +// file's value alone, so setup can be run again to serve another project +// without restating everything else. type Changes struct { // Trust is the trust mode, "" to keep the file's. Trust admission.TrustMode // Allow replaces the allowlist when non-empty; it implies allowlist mode. Allow []int64 - // Routes maps a project (bucket) id to the directory its work runs in, - // as the operator typed it; ResolveDir makes it the approved entry. - Routes map[int64]string - // Classes sets a routed project's class; an empty class clears it. + // Serve are the project (bucket) ids to serve. A project already served + // keeps its class and watch_completions. + Serve []int64 + // Classes sets a served project's class; an empty class clears it. Classes map[int64]string // WatchCompletions turns watch_completions on (true) or off (false) for - // a routed project. + // a served project. WatchCompletions map[int64]bool - // Remove drops projects' routes. + // Remove stops serving projects. Remove []int64 Driver string @@ -44,7 +42,7 @@ type Changes struct { // pass Validate, which Save runs. func Apply(f File, ch Changes) (File, error) { out := f - out.Projects = make(map[int64]admission.Route, len(f.Projects)) + out.Projects = make(map[int64]admission.Project, len(f.Projects)) for id, r := range f.Projects { out.Projects[id] = r } @@ -55,27 +53,25 @@ func Apply(f File, ch Changes) (File, error) { } for _, id := range ch.Remove { - if _, routed := ch.Routes[id]; routed { - return File{}, fmt.Errorf("project %d is both routed and removed in one run", id) + if slices.Contains(ch.Serve, id) { + return File{}, fmt.Errorf("project %d is both served and removed in one run", id) } - delete(out.Projects, id) // removing a route that is not there is already done + delete(out.Projects, id) // no longer serving a project that was not served is already done } - for id, raw := range ch.Routes { + for _, id := range ch.Serve { if id <= 0 { - return File{}, fmt.Errorf("route: %d is not a project id", id) + return File{}, fmt.Errorf("serve: %d is not a project id", id) } - dir, err := ResolveDir(raw) - if err != nil { - return File{}, fmt.Errorf("route for project %d: %w", id, err) + if _, served := out.Projects[id]; !served { + // A project already served keeps its class and watch_completions; + // serving it again is not a reset. + out.Projects[id] = admission.Project{} } - r := out.Projects[id] - r.Path = dir - out.Projects[id] = r } for id, class := range ch.Classes { r, ok := out.Projects[id] if !ok { - return File{}, fmt.Errorf("class for project %d: the project has no route; add one with --route %d=", id, id) + return File{}, fmt.Errorf("class for project %d: the project is not served; serve it with --serve %d", id, id) } if class != "" && !ValidClass(class) { return File{}, fmt.Errorf("class %q for project %d: use lowercase letters, digits, - or _, at most 40", class, id) @@ -86,7 +82,7 @@ func Apply(f File, ch Changes) (File, error) { for id, on := range ch.WatchCompletions { r, ok := out.Projects[id] if !ok { - return File{}, fmt.Errorf("watch_completions for project %d: the project has no route; add one with --route %d=", id, id) + return File{}, fmt.Errorf("watch_completions for project %d: the project is not served; serve it with --serve %d", id, id) } r.WatchCompletions = on out.Projects[id] = r @@ -142,38 +138,3 @@ func applyTrust(t *admission.Trust, ch Changes) error { t.Mode = mode return nil } - -// ResolveDir turns a route directory as typed into the entry connect.json -// approves: ~ expanded, absolute, symlinks resolved, and required to be an -// existing directory. Resolving symlinks means the approved entry names the -// directory work will really run in, so a link retargeted later does not -// move the route with it. -func ResolveDir(raw string) (string, error) { - if strings.TrimSpace(raw) == "" { - return "", errors.New("the directory is empty") - } - path := raw - if path == "~" || strings.HasPrefix(path, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("expand ~: %w", err) - } - path = filepath.Join(home, strings.TrimPrefix(path, "~")) - } - abs, err := filepath.Abs(path) - if err != nil { - return "", err - } - resolved, err := filepath.EvalSymlinks(abs) - if err != nil { - return "", fmt.Errorf("%s: %w", abs, err) - } - info, err := os.Stat(resolved) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", fmt.Errorf("%s is not a directory", resolved) - } - return filepath.Clean(resolved), nil -} diff --git a/internal/connector/setup/apply_test.go b/internal/connector/setup/apply_test.go index 877358c3d..3f5a636e4 100644 --- a/internal/connector/setup/apply_test.go +++ b/internal/connector/setup/apply_test.go @@ -3,8 +3,6 @@ package setup import ( - "os" - "path/filepath" "testing" "time" @@ -66,46 +64,36 @@ func TestApplyTrust(t *testing.T) { }) } -func TestApplyRoutes(t *testing.T) { +func TestApplyServedProjects(t *testing.T) { base := validFile(t) - t.Run("a route resolves to the real directory", func(t *testing.T) { - target := t.TempDir() - link := filepath.Join(t.TempDir(), "app") - require.NoError(t, os.Symlink(target, link)) - - out, err := Apply(base, Changes{Routes: map[int64]string{777: link}}) - require.NoError(t, err) - want, err := filepath.EvalSymlinks(target) + t.Run("serving a project adds it with no settings", func(t *testing.T) { + out, err := Apply(base, Changes{Serve: []int64{777}}) require.NoError(t, err) - assert.Equal(t, want, out.Projects[777].Path, "the approved entry names where work really runs") + require.Contains(t, out.Projects, int64(777)) + assert.Equal(t, admission.Project{}, out.Projects[777]) }) - t.Run("rerouting keeps class and watch_completions", func(t *testing.T) { - dir := t.TempDir() - out, err := Apply(base, Changes{Routes: map[int64]string{projectID: dir}}) + t.Run("serving a project again keeps class and watch_completions", func(t *testing.T) { + out, err := Apply(base, Changes{Serve: []int64{projectID}}) require.NoError(t, err) assert.Equal(t, "internal", out.Projects[projectID].Class) assert.True(t, out.Projects[projectID].WatchCompletions) }) - t.Run("a directory that does not exist is refused", func(t *testing.T) { - _, err := Apply(base, Changes{Routes: map[int64]string{777: filepath.Join(t.TempDir(), "missing")}}) + t.Run("a project id that is not one is refused", func(t *testing.T) { + _, err := Apply(base, Changes{Serve: []int64{0}}) assert.Error(t, err) - }) - t.Run("a file is not a directory", func(t *testing.T) { - file := filepath.Join(t.TempDir(), "f") - require.NoError(t, os.WriteFile(file, nil, 0o600)) - _, err := Apply(base, Changes{Routes: map[int64]string{777: file}}) + _, err = Apply(base, Changes{Serve: []int64{-1}}) assert.Error(t, err) }) - t.Run("class and watch_completions need a route", func(t *testing.T) { + t.Run("class and watch_completions need the project served", func(t *testing.T) { _, err := Apply(base, Changes{Classes: map[int64]string{777: "internal"}}) assert.Error(t, err) _, err = Apply(base, Changes{WatchCompletions: map[int64]bool{777: true}}) assert.Error(t, err) }) - t.Run("class and watch_completions apply with a new route", func(t *testing.T) { + t.Run("class and watch_completions apply to a project served in the same run", func(t *testing.T) { out, err := Apply(base, Changes{ - Routes: map[int64]string{777: t.TempDir()}, + Serve: []int64{777}, Classes: map[int64]string{777: "client-work"}, WatchCompletions: map[int64]bool{777: true, projectID: false}, }) @@ -124,10 +112,10 @@ func TestApplyRoutes(t *testing.T) { assert.NotContains(t, out.Projects, projectID) again, err := Apply(out, Changes{Remove: []int64{projectID}}) - require.NoError(t, err, "removing a route that is already gone is idempotent") + require.NoError(t, err, "unserving a project that is already gone is idempotent") assert.Equal(t, out.Projects, again.Projects) - _, err = Apply(base, Changes{Remove: []int64{projectID}, Routes: map[int64]string{projectID: t.TempDir()}}) - assert.Error(t, err, "routing and removing one project in one run") + _, err = Apply(base, Changes{Remove: []int64{projectID}, Serve: []int64{projectID}}) + assert.Error(t, err, "serving and removing one project in one run") }) } @@ -138,15 +126,3 @@ func TestApplyDispatchSettings(t *testing.T) { assert.Equal(t, 4, out.Concurrency) assert.Equal(t, Duration(90*time.Minute), out.Deadline) } - -func TestResolveDirExpandsHome(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - require.NoError(t, os.Mkdir(filepath.Join(home, "work"), 0o700)) - - got, err := ResolveDir("~/work") - require.NoError(t, err) - want, err := filepath.EvalSymlinks(filepath.Join(home, "work")) - require.NoError(t, err) - assert.Equal(t, want, got) -} diff --git a/internal/connector/setup/checks.go b/internal/connector/setup/checks.go index 834cbb1c8..2c5a663cf 100644 --- a/internal/connector/setup/checks.go +++ b/internal/connector/setup/checks.go @@ -296,16 +296,48 @@ func verifyPerson(ctx context.Context, r Reader, name string, p Person, agentID return c } -// RouteChecks reads each routed project the way admission will, as the +// ProjectChecks reads each served project the way admission will, as the // agent: the project, and its people (project trust mode's membership read). -func RouteChecks(ctx context.Context, r Reader, f File) []Check { +// +// firstSetup says there is no connect.json yet. Serving no project is two +// different questions depending on it, and answering both with one rule left +// the operator no way to turn the agent off through the interface that +// turned it on: an explicit --unserve of the last project produced a valid +// empty file that the readiness check then refused to write (Copilot on +// #765). A first setup that serves nothing has not been set up, and is +// refused. Withdrawing the last project is a thing an operator may mean — +// it is how you stop the agent working anywhere without editing the trust +// anchor by hand — so it is written, and warned about. +func ProjectChecks(ctx context.Context, r Reader, f File, firstSetup bool) []Check { if len(f.Projects) == 0 { - return []Check{{ - Name: "Routes", - Status: StatusFail, - Message: "No project is routed: every mention would get a holding reply and no work", - Hint: "Add one: basecamp connect setup -P " + f.Profile + " --route =", - }} + // Not every mention is answered, and it is not only mentions that + // are. Gate checks scope and trust before it checks whether the + // project is served, so an event it drops there is discarded and + // never replied to; what reaches the served check is a mention from + // a trusted person or an operator's assignment, and both of those + // carry Acknowledge, so both get the holding reply. Telling an + // operator that every mention is answered promises more than the + // code does. + c := Check{ + Name: "Projects", + Status: StatusWarn, + Message: "No project is served: this agent is handed no work at all, and the connector will start and do nothing. " + + "A mention from a trusted person, or an assignment from the operator, gets a holding reply; " + + "anything else — an untrusted mention, a project outside --project, a subscription, a completion — is discarded unanswered.", + // Quoted, not interpolated: this is a line we tell an operator + // to paste into a shell, and a profile name comes from a + // configuration file, which is not held to the check that + // applied when the profile was created. Validate happens to + // refuse an unsafe name before this runs today; that is a guard + // in another package's call order, not one at the point of use. + Hint: "Serve one: basecamp connect setup -P " + richtext.ShellQuote(f.Profile) + " --serve ", + } + if firstSetup { + c.Status = StatusFail + c.Message = "No project is served: a mention from a trusted person, or an assignment from the operator, " + + "would get a holding reply and no work; anything else would be discarded unanswered" + } + return []Check{c} } ids := make([]int64, 0, len(f.Projects)) for id := range f.Projects { @@ -315,22 +347,13 @@ func RouteChecks(ctx context.Context, r Reader, f File) []Check { checks := make([]Check, 0, len(ids)) for _, id := range ids { - checks = append(checks, routeCheck(ctx, r, f.Agent.Kind, id, f.Projects[id].Path)) + checks = append(checks, projectCheck(ctx, r, f.Agent.Kind, id)) } return checks } -func routeCheck(ctx context.Context, r Reader, kind string, id int64, path string) Check { +func projectCheck(ctx context.Context, r Reader, kind string, id int64) Check { c := Check{Name: fmt.Sprintf("Project %d", id)} - // The directory is checked as a new route's is, whether the route was - // passed now or kept from connect.json: it must still resolve to itself, - // an existing directory. - if resolved, err := ResolveDir(path); err != nil || resolved != path { - c.Status = StatusFail - c.Message = "The route's directory is no longer usable: " + richtext.SanitizeSingleLine(path) - c.Hint = fmt.Sprintf("Route the project again: --route %d=, or remove it: --remove-route %d.", id, id) - return c - } for _, read := range []struct { what string run func(context.Context, int64) error @@ -356,7 +379,7 @@ func routeCheck(ctx context.Context, r Reader, kind string, id int64, path strin return c } c.Status = StatusPass - c.Message = "Readable by the agent, routed to " + richtext.SanitizeSingleLine(path) + c.Message = "Readable by the agent" return c } diff --git a/internal/connector/setup/checks_test.go b/internal/connector/setup/checks_test.go index 2bf8e0453..ecb550d28 100644 --- a/internal/connector/setup/checks_test.go +++ b/internal/connector/setup/checks_test.go @@ -9,8 +9,7 @@ import ( "math" "net/http" "net/http/httptest" - "os" - "path/filepath" + "os/exec" "sync" "testing" "time" @@ -53,11 +52,11 @@ func status(code int) error { // The known bc3 limitation: an Agent identity is refused the reads admission // makes. Setup has to say so, rather than write a file the connector would // start on and then block every event against. -func TestRouteChecksNameTheAgentReadRefusal(t *testing.T) { +func TestProjectChecksNameTheAgentReadRefusal(t *testing.T) { f := validFile(t) r := &fakeReader{projectErr: map[int64]error{projectID: status(http.StatusForbidden)}} - checks := RouteChecks(context.Background(), r, f) + checks := ProjectChecks(context.Background(), r, f, false) require.Len(t, checks, 2) byName := map[string]Check{} for _, c := range checks { @@ -72,12 +71,12 @@ func TestRouteChecksNameTheAgentReadRefusal(t *testing.T) { assert.Equal(t, StatusPass, byName[fmt.Sprintf("Project %d", otherProj)].Status) } -func TestRouteChecksReadProjectPeopleToo(t *testing.T) { +func TestProjectChecksReadProjectPeopleToo(t *testing.T) { f := validFile(t) r := &fakeReader{projectPplErr: map[int64]error{otherProj: status(http.StatusForbidden)}} var failed []Check - for _, c := range RouteChecks(context.Background(), r, f) { + for _, c := range ProjectChecks(context.Background(), r, f, false) { if c.Status == StatusFail { failed = append(failed, c) } @@ -87,12 +86,12 @@ func TestRouteChecksReadProjectPeopleToo(t *testing.T) { assert.Contains(t, failed[0].Message, "people") } -func TestRouteChecksForABotUserSayToAddTheAgent(t *testing.T) { +func TestProjectChecksForABotUserSayToAddTheAgent(t *testing.T) { f := validFile(t) f.Agent = Agent{PersonID: agentID, Kind: KindBotUser, IdentityID: 99} r := &fakeReader{projectErr: map[int64]error{projectID: status(http.StatusNotFound)}} - for _, c := range RouteChecks(context.Background(), r, f) { + for _, c := range ProjectChecks(context.Background(), r, f, false) { if c.Status != StatusFail { continue } @@ -103,12 +102,31 @@ func TestRouteChecksForABotUserSayToAddTheAgent(t *testing.T) { t.Fatal("the refused project did not fail") } -func TestRouteChecksWarnWithNoRoutes(t *testing.T) { +// Serving no project is two different questions. A first setup that serves +// none has not been set up, and is refused. Withdrawing the last one from an +// existing setup is a thing an operator may mean — it is how the agent is +// turned off through the interface that turned it on — so it is written, and +// warned about (Copilot on #765). +func TestProjectChecksSeparateAFirstSetupFromWithdrawingTheLastProject(t *testing.T) { f := validFile(t) - f.Projects = map[int64]admission.Route{} - checks := RouteChecks(context.Background(), &fakeReader{}, f) - require.Len(t, checks, 1) - assert.Equal(t, StatusFail, checks[0].Status, "a connector with no route does no work, so it is not ready") + f.Projects = map[int64]admission.Project{} + + first := ProjectChecks(context.Background(), &fakeReader{}, f, true) + require.Len(t, first, 1) + assert.Equal(t, StatusFail, first[0].Status, "a first setup that serves nothing has not been set up") + + withdrawn := ProjectChecks(context.Background(), &fakeReader{}, f, false) + require.Len(t, withdrawn, 1) + assert.Equal(t, StatusWarn, withdrawn[0].Status, "and withdrawing the last one is written, not refused") + assert.Contains(t, withdrawn[0].Message, "no work at all") + assert.Contains(t, withdrawn[0].Hint, "--serve") + + // A warn is not a failure, so nothing about it stops the file being + // written. + r := &Report{Written: true} + r.Add(withdrawn...) + assert.Empty(t, r.Failed()) + assert.True(t, r.Ready()) } func TestTicketCheck(t *testing.T) { @@ -256,17 +274,6 @@ func TestSDKReaderMintsAndDiscardsTheTicket(t *testing.T) { } } -// A route directory's name reaches a one-line terminal sink; control -// characters in it must not restyle or break that line. -func TestRouteChecksSanitizeThePath(t *testing.T) { - f := validFile(t) - f.Projects = map[int64]admission.Route{projectID: {Path: "/work/evil\x1b[31m\nFAKE ✓ line"}} - checks := RouteChecks(context.Background(), &fakeReader{}, f) - require.Len(t, checks, 1) - assert.NotContains(t, checks[0].Message, "\x1b") - assert.NotContains(t, checks[0].Message, "\n") -} - // ErrorText is the one formatter for read errors: an HTTP answer is its // status and nothing the server wrote. func TestErrorTextKeepsNothingTheServerWrote(t *testing.T) { @@ -304,25 +311,6 @@ func TestScopeCheck(t *testing.T) { } } -// A route kept from connect.json is checked as a new one is: a directory -// that has since gone is not ready. -func TestRouteChecksCheckEveryRoutesDirectory(t *testing.T) { - f := validFile(t) - gone := f.Projects[otherProj].Path - require.NoError(t, os.Remove(gone)) - file := filepath.Join(t.TempDir(), "file") - require.NoError(t, os.WriteFile(file, nil, 0o600)) - f.Projects[777] = admission.Route{Path: file} - - byName := map[string]Check{} - for _, c := range RouteChecks(context.Background(), &fakeReader{}, f) { - byName[c.Name] = c - } - assert.Equal(t, StatusPass, byName[fmt.Sprintf("Project %d", projectID)].Status) - assert.Equal(t, StatusFail, byName[fmt.Sprintf("Project %d", otherProj)].Status, "a directory that is gone") - assert.Equal(t, StatusFail, byName["Project 777"].Status, "a file where the directory was") -} - // The lifetime bound is compared in whole seconds, so no value wraps past // it: the largest int, and the first value whose conversion to a Duration // overflows, are refused like any other absurd lifetime. @@ -340,3 +328,28 @@ func TestUsableTicketBoundsTheLifetimeWithoutOverflow(t *testing.T) { assert.False(t, UsableTicket(ticket(math.MaxInt64/int(time.Second)+1)), "just past it, where the conversion overflows") assert.False(t, UsableTicket(ticket(math.MaxInt64)), "wraps to -1s when converted first") } + +// The hint is a line we tell an operator to paste into a shell, and it +// carries a profile name read from a configuration file — which is not held +// to the check that applied when the profile was created. An embedded single +// quote is the case that tells quoting apart from wrapping. +func TestProjectChecksQuoteTheProfileInTheHint(t *testing.T) { + f := validFile(t) + f.Projects = map[int64]admission.Project{} + f.Profile = `it's; echo pwned` + quoted := `'it'\''s; echo pwned'` + + for _, firstSetup := range []bool{true, false} { + checks := ProjectChecks(context.Background(), &fakeReader{}, f, firstSetup) + require.Len(t, checks, 1) + hint := checks[0].Hint + assert.NotContains(t, hint, "-P it's", "first setup %v: the name is not interpolated raw", firstSetup) + assert.Contains(t, hint, "-P "+quoted, "first setup %v", firstSetup) + + // And a real shell reads that word back as the name that went in, + // rather than running what follows the quote. + out, err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "printf %s "+quoted).Output() + require.NoError(t, err) + assert.Equal(t, f.Profile, string(out), "first setup %v", firstSetup) + } +} diff --git a/internal/connector/setup/file.go b/internal/connector/setup/file.go index 4574c330c..e20375d57 100644 --- a/internal/connector/setup/file.go +++ b/internal/connector/setup/file.go @@ -5,11 +5,15 @@ // // # connect.json // -// connect.json is the only authority for which directory a project maps to -// and for who may drive the agent. It is local policy: nothing read from -// Basecamp adds a route or widens trust. Its admission part (trust and -// projects) is exactly what admission.ParsePolicy reads; the rest belongs to -// dispatch. +// connect.json is the only local authority for which Basecamp projects may +// drive the agent and for who may drive it. It is local policy: nothing read +// from Basecamp serves a project or widens trust. Its admission part (trust +// and projects) is exactly what admission.ParsePolicy reads; the rest belongs +// to dispatch. +// +// No directory is associated with a project. The connector runs where it was +// started, and a task that needs a clone or a directory of its own is the +// agent's business to make. // // Because the file is the trust anchor, a copy another user could have // written is not one to act on. Save writes it owner-only (0600, in 0700 @@ -100,9 +104,10 @@ type File struct { Agent Agent `json:"agent"` // Trust and Projects are admission's, in admission's own types, so the - // two cannot drift. - Trust admission.Trust `json:"trust"` - Projects map[int64]admission.Route `json:"projects"` + // two cannot drift. Projects is the list of Basecamp projects this agent + // serves, keyed by project id. + Trust admission.Trust `json:"trust"` + Projects map[int64]admission.Project `json:"projects"` Driver string `json:"driver"` // Worker is the coding agent the driver runs: claude, or another row of @@ -114,7 +119,8 @@ type File struct { // LegacyWorktrees is the --worktrees setting of a connector that gave // each task a git worktree of its own. Worktrees are gone: a task runs - // where its route says, and nothing here reads this. It is still a field + // where the connector was started, and nothing here reads this. It is + // still a field // because Parse refuses an unknown key, and a connect.json written // before they went has "worktrees" in it — accepting it is what lets // that file still open. Parse zeroes it, and omitempty keeps it out of @@ -164,7 +170,7 @@ func New(profile string) File { Version: Version, Profile: profile, Trust: admission.Trust{Mode: admission.TrustOperator}, - Projects: map[int64]admission.Route{}, + Projects: map[int64]admission.Project{}, Driver: DefaultDriver, Worker: DefaultWorker, Concurrency: DefaultConcurrency, @@ -241,12 +247,9 @@ func (f File) Validate() error { if _, err := f.Policy(f.Agent.PersonID); err != nil { return err } - for bucket, route := range f.Projects { - if !filepath.IsAbs(route.Path) || filepath.Clean(route.Path) != route.Path { - return fmt.Errorf("route for project %d: path %q is not a clean absolute path", bucket, route.Path) - } - if route.Class != "" && !ValidClass(route.Class) { - return fmt.Errorf("route for project %d: class %q must be lowercase letters, digits, - or _, at most 40", bucket, route.Class) + for bucket, project := range f.Projects { + if project.Class != "" && !ValidClass(project.Class) { + return fmt.Errorf("served project %d: class %q must be lowercase letters, digits, - or _, at most 40", bucket, project.Class) } } switch f.Driver { @@ -280,7 +283,16 @@ func (f File) WorkerName() string { // (only the last would count, so the file would not say what it reads as), // and anything after the object. func Parse(data []byte) (File, error) { - if err := refuseDuplicateKeys(data); err != nil { + // admission's, not setup's own: one walk, so the reader that authorizes + // and the reader that refuses cannot disagree about what a key says. + if err := admission.CheckCanonicalKeys(data); err != nil { + return File{}, fmt.Errorf("parse connect.json: %w", err) + } + // admission.Project refuses a malformed entry itself, so both readers + // fail closed. This runs first only to name the project in the message: + // a map value's own error cannot say which key it came from, and + // connect.json is a file an operator edits by hand. + if err := refuseMalformedProjects(data); err != nil { return File{}, fmt.Errorf("parse connect.json: %w", err) } dec := json.NewDecoder(bytes.NewReader(data)) @@ -292,10 +304,17 @@ func Parse(data []byte) (File, error) { if _, err := dec.Token(); !errors.Is(err, io.EOF) { return File{}, errors.New("parse connect.json: trailing data after the object") } - // Read, and forgotten: see LegacyWorktrees. + // Read, and forgotten: see LegacyWorktrees and admission.Project's + // LegacyPath. Both keys are in every connect.json written before the + // directories went, and DisallowUnknownFields would refuse the file + // outright without a field to decode them into. f.LegacyWorktrees = false if f.Projects == nil { - f.Projects = map[int64]admission.Route{} + f.Projects = map[int64]admission.Project{} + } + for id, project := range f.Projects { + project.LegacyPath = "" + f.Projects[id] = project } if err := f.Validate(); err != nil { return File{}, err @@ -303,73 +322,40 @@ func Parse(data []byte) (File, error) { return f, nil } -// canonicalKey reports whether a key is in its one canonical spelling: -// lowercase letters, digits and underscores for names, plain decimal for -// project ids. -func canonicalKey(key string) bool { - if n, err := strconv.ParseInt(key, 10, 64); err == nil { - return strconv.FormatInt(n, 10) == key +// refuseMalformedProjects names the project an entry belongs to when the +// entry is one admission.Project refuses. It decodes each entry through that +// type, so it refuses everything the type refuses — a null entry, an unknown +// field, a value of the wrong type, a legacy path that is not the absolute +// POSIX path the old writer wrote — and not a null entry alone. The strict +// decode in Parse refuses the same file; what it cannot say is which of the +// served projects carried the entry. A malformed trust anchor withholds +// authorization either way; this only makes the refusal findable. +func refuseMalformedProjects(data []byte) error { + var shape struct { + Projects map[string]json.RawMessage `json:"projects"` } - if key == "" { - return false + // A document this cannot read is not this check's to report: the strict + // decode that follows says it better. This exists only to name a + // project, so it stands aside rather than competing. + if json.Unmarshal(data, &shape) != nil { + return nil //nolint:nilerr // the strict decode below reports it better } - for _, r := range key { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { - return false - } + ids := make([]string, 0, len(shape.Projects)) + for id := range shape.Projects { + ids = append(ids, id) } - return true -} - -// refuseDuplicateKeys walks the JSON document and refuses any object that -// names a key twice. encoding/json matches field names case-insensitively -// and parses project ids as numbers, so "Trust" is "trust" and "048699913" -// is 48699913 to it; every key must therefore be in its one canonical -// spelling, which makes an exact comparison a complete one. -func refuseDuplicateKeys(data []byte) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - var walk func() error - walk = func() error { - tok, err := dec.Token() - if err != nil { - return err + slices.Sort(ids) + for _, id := range ids { + entry := shape.Projects[id] + if string(bytes.TrimSpace(entry)) == "null" { + return fmt.Errorf("project %s: its entry is null, which is not a served project; write {} for one with no settings, or take the project out", id) } - delim, ok := tok.(json.Delim) - if !ok { - return nil + var p admission.Project + if err := json.Unmarshal(entry, &p); err != nil { + return fmt.Errorf("project %s: %w", id, err) } - switch delim { - case '{': - seen := map[string]bool{} - for dec.More() { - keyTok, err := dec.Token() - if err != nil { - return err - } - key, _ := keyTok.(string) - if !canonicalKey(key) { - return fmt.Errorf("key %q is not spelled canonically: names are lowercase, project ids plain decimal", key) - } - if seen[key] { - return fmt.Errorf("key %q appears twice in one object", key) - } - seen[key] = true - if err := walk(); err != nil { - return err - } - } - case '[': - for dec.More() { - if err := walk(); err != nil { - return err - } - } - } - _, err = dec.Token() // the closing delimiter - return err } - return walk() + return nil } // VerifyAgent refuses a connect.json that does not describe the identity a diff --git a/internal/connector/setup/file_test.go b/internal/connector/setup/file_test.go index 39e9fc6ec..5aeed573b 100644 --- a/internal/connector/setup/file_test.go +++ b/internal/connector/setup/file_test.go @@ -33,8 +33,8 @@ func validFile(t *testing.T) File { f.AccountID = "2914079" f.Agent = Agent{PersonID: agentID, Kind: KindAgent} f.Trust.OperatorID = operatorID - f.Projects[projectID] = admission.Route{Path: t.TempDir(), Class: "internal", WatchCompletions: true} - f.Projects[otherProj] = admission.Route{Path: t.TempDir()} + f.Projects[projectID] = admission.Project{Class: "internal", WatchCompletions: true} + f.Projects[otherProj] = admission.Project{} return f } @@ -98,16 +98,16 @@ func TestLoadReportsAMissingFileAsNotExist(t *testing.T) { } // A misspelled key silently ignored is a setting the operator believes is on -// and is not; for trust and routes that is not a harmless typo. +// and is not; for trust and served projects that is not a harmless typo. func TestParseRefusesUnknownKeys(t *testing.T) { data, err := json.Marshal(validFile(t)) require.NoError(t, err) var raw map[string]any require.NoError(t, json.Unmarshal(data, &raw)) projects := raw["projects"].(map[string]any) - route := projects["48699913"].(map[string]any) - delete(route, "watch_completions") - route["watch_completion"] = true + served := projects["48699913"].(map[string]any) + delete(served, "watch_completions") + served["watch_completion"] = true data, err = json.Marshal(raw) require.NoError(t, err) @@ -143,6 +143,37 @@ func TestParseAcceptsAndForgetsTheWorktreesSettingOfAnOlderFile(t *testing.T) { assert.NotContains(t, out, "worktrees") } +// A connect.json written while projects were routed to directories still +// opens. Every one of them has "path" on every project, and the parse is +// strict, so the key has to stay known for the file to load at all — +// deleting the field outright would have stopped every connector already +// set up. +func TestParseAcceptsAndForgetsTheProjectPathsOfAnOlderFile(t *testing.T) { + data, err := json.Marshal(validFile(t)) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + projects := raw["projects"].(map[string]any) + for id, project := range projects { + entry := project.(map[string]any) + require.NotContains(t, entry, "path", "nothing written now carries it") + entry["path"] = "/work/" + id + } + data, err = json.Marshal(raw) + require.NoError(t, err) + + f, err := Parse(data) + require.NoError(t, err, "a file written by a connector that routed projects still opens") + for id, project := range f.Projects { + assert.Empty(t, project.LegacyPath, "project %d: the path is read, then forgotten", id) + } + + // And writing that file back takes the key out for good. + again, err := json.Marshal(f) + require.NoError(t, err) + assert.NotContains(t, string(again), `"path"`) +} + func TestValidateFailsClosed(t *testing.T) { for name, mutate := range map[string]func(*File){ "wrong version": func(f *File) { f.Version = 2 }, @@ -161,17 +192,14 @@ func TestValidateFailsClosed(t *testing.T) { "agent in the allowlist": func(f *File) { f.Trust = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{agentID}} }, - "relative route": func(f *File) { f.Projects[projectID] = admission.Route{Path: "work/app"} }, - "unclean route": func(f *File) { f.Projects[projectID] = admission.Route{Path: "/work/../etc"} }, - "route without a path": func(f *File) { f.Projects[projectID] = admission.Route{} }, - "class with spaces": func(f *File) { f.Projects[projectID] = admission.Route{Path: "/work", Class: "a b"} }, + "class with spaces": func(f *File) { f.Projects[projectID] = admission.Project{Class: "a b"} }, "unknown driver": func(f *File) { f.Driver = "fork" }, "no concurrency": func(f *File) { f.Concurrency = 0 }, "too much concurrency": func(f *File) { f.Concurrency = MaxConcurrency + 1 }, "deadline too short": func(f *File) { f.Deadline = Duration(time.Second) }, "deadline too long": func(f *File) { f.Deadline = Duration(48 * time.Hour) }, - "route for a non-project": func(f *File) { - f.Projects[-1] = admission.Route{Path: "/work"} + "served project that is not a project": func(f *File) { + f.Projects[-1] = admission.Project{} }, } { t.Run(name, func(t *testing.T) { @@ -206,16 +234,16 @@ func TestParseRefusesDuplicateKeysAndTrailingData(t *testing.T) { require.NoError(t, err) for name, doc := range map[string]string{ - "trailing ]": string(data) + "]", - "trailing }": string(data) + "}", - "trailing object": string(data) + "{}", - "duplicate top-level": `{"driver":"acp",` + string(data[1:]), - "duplicate operator_id": strings.Replace(string(data), `"operator_id":`, `"operator_id":1,"operator_id":`, 1), - "duplicate nested route": strings.Replace(string(data), `"48699913":{`, `"48699913":{"path":"/elsewhere",`, 1), - "case variant key": strings.Replace(string(data), `"trust":`, `"Trust":`, 1), - "long s variant key": strings.Replace(string(data), `"concurrency"`, `"concurrencſ"`, 1), - "padded project id": strings.Replace(string(data), `"48699913":{`, `"048699913":{`, 1), - "signed project id": strings.Replace(string(data), `"48699913":{`, `"+48699913":{`, 1), + "trailing ]": string(data) + "]", + "trailing }": string(data) + "}", + "trailing object": string(data) + "{}", + "duplicate top-level": `{"driver":"acp",` + string(data[1:]), + "duplicate operator_id": strings.Replace(string(data), `"operator_id":`, `"operator_id":1,"operator_id":`, 1), + "duplicate nested key": strings.Replace(string(data), `"48699913":{`, `"48699913":{"class":"other",`, 1), + "case variant key": strings.Replace(string(data), `"trust":`, `"Trust":`, 1), + "long s variant key": strings.Replace(string(data), `"concurrency"`, `"concurrencſ"`, 1), + "padded project id": strings.Replace(string(data), `"48699913":{`, `"048699913":{`, 1), + "signed project id": strings.Replace(string(data), `"48699913":{`, `"+48699913":{`, 1), } { t.Run(name, func(t *testing.T) { require.NotEqual(t, string(data), doc, "the fixture changed the document") @@ -307,3 +335,94 @@ func TestWorkerIsOneSetupKnowsAndDefaultsToClaude(t *testing.T) { require.NoError(t, err) assert.Equal(t, WorkerClaude, next.Worker) } + +// The same refusal at the other reader: setup writes and re-reads the trust +// anchor, so a null entry must not survive a round through it either +// (Copilot on #765). +func TestParseRefusesANullProjectEntry(t *testing.T) { + data, err := json.Marshal(validFile(t)) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + raw["projects"].(map[string]any)["48699913"] = nil + data, err = json.Marshal(raw) + require.NoError(t, err) + + _, err = Parse(data) + require.Error(t, err, "a null entry is not a served project") + assert.Contains(t, err.Error(), "48699913", "and setup names the project, which the type's own refusal cannot") + + // And an empty object still is one. + raw["projects"].(map[string]any)["48699913"] = map[string]any{} + data, err = json.Marshal(raw) + require.NoError(t, err) + f, err := Parse(data) + require.NoError(t, err) + assert.Contains(t, f.Projects, projectID) +} + +// The same refusal at setup's reader, and it names the project: these are +// files a person edits by hand (Copilot on #765). +func TestParseRefusesAMalformedLegacyPath(t *testing.T) { + for name, path := range map[string]any{ + "null": nil, + "empty": "", + "relative": "work/app", + "unclean": "/work/../etc", + "a NUL byte": "/work/\x00app", + } { + t.Run(name, func(t *testing.T) { + data, err := json.Marshal(validFile(t)) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + raw["projects"].(map[string]any)["48699913"] = map[string]any{"path": path} + data, err = json.Marshal(raw) + require.NoError(t, err) + + _, err = Parse(data) + require.Error(t, err, "the old validation refused this file") + assert.Contains(t, err.Error(), "48699913", "and the refusal names the project") + }) + } +} + +// The other compatibility field on this file, checked against the same +// question LegacyPath failed: what did the old validation guarantee about +// this value, and is that guarantee still enforced while it is read? +// +// For worktrees the answer is "nothing beyond the type", and it is still +// enforced: a non-bool is refused by the decode, as it always was. A null +// reads as false, which is what an absent key reads as and what the value +// means now — and, unlike a malformed path, it is not a shape the old +// validation refused. Adding a refusal here would not restore a lost check; +// it would be a new strictness that turns away files older setup accepted. +// So this is deliberately unchanged, and that decision is pinned here rather +// than left to the next reader to re-derive (Copilot on #765). +func TestTheWorktreesCompatibilityFieldKeepsItsOldShapeRules(t *testing.T) { + with := func(v any) ([]byte, error) { + data, err := json.Marshal(validFile(t)) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + raw["worktrees"] = v + return json.Marshal(raw) + } + + for name, v := range map[string]any{"true": true, "false": false, "null": nil} { + t.Run(name, func(t *testing.T) { + data, err := with(v) + require.NoError(t, err) + f, err := Parse(data) + require.NoError(t, err, "accepted before this change, and still accepted") + assert.False(t, f.LegacyWorktrees, "and read, then forgotten") + }) + } + + t.Run("not a bool", func(t *testing.T) { + data, err := with("yes") + require.NoError(t, err) + _, err = Parse(data) + assert.Error(t, err, "the one thing the type guaranteed, still guaranteed") + }) +} diff --git a/internal/connector/setup/report.go b/internal/connector/setup/report.go index cdf2aa27b..6ea16e337 100644 --- a/internal/connector/setup/report.go +++ b/internal/connector/setup/report.go @@ -17,8 +17,9 @@ type Report struct { AgentKind string OperatorID int64 TrustMode string - Routes int - Written bool + // Projects is how many Basecamp projects the file serves. + Projects int + Written bool checks []Check } @@ -57,9 +58,9 @@ func (r *Report) MarshalJSON() ([]byte, error) { AgentKind string `json:"agent_kind"` OperatorID int64 `json:"operator_id"` TrustMode string `json:"trust_mode"` - Routes int `json:"routes"` + Projects int `json:"projects"` Written bool `json:"written"` Ready bool `json:"ready"` Checks []Check `json:"checks"` - }{r.Path, r.Profile, r.AccountID, r.AgentPersonID, r.AgentKind, r.OperatorID, r.TrustMode, r.Routes, r.Written, r.Ready(), r.Checks()}) + }{r.Path, r.Profile, r.AccountID, r.AgentPersonID, r.AgentKind, r.OperatorID, r.TrustMode, r.Projects, r.Written, r.Ready(), r.Checks()}) } diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go index deef530a9..b1a722ca0 100644 --- a/internal/mcpserver/connect.go +++ b/internal/mcpserver/connect.go @@ -20,7 +20,7 @@ import ( // It exists only on a server started for one task — a connector state // directory and that task's token — and every action is bound to the task // the token names. There is no listing: a worker never reads other tasks. -// Nothing it returns carries the token, a feed position or a route; the +// Nothing it returns carries the token, a feed position or a directory; the // instruction is an allowlist of fields (connector.Instruction). const ( connectDomainKey = "connect" diff --git a/internal/richtext/shellquote.go b/internal/richtext/shellquote.go new file mode 100644 index 000000000..8002b08d7 --- /dev/null +++ b/internal/richtext/shellquote.go @@ -0,0 +1,39 @@ +package richtext + +import "strings" + +// ShellQuote renders s so a POSIX shell reads it as one literal word: +// unchanged when nothing in it can mean anything to a shell, and otherwise +// wrapped in single quotes with each embedded single quote spliced out and +// back in as: +// +// '\'' +// +// that is: quote, backslash, quote, quote. It is written as an indented +// block on purpose — gofmt reformats doc-comment prose and rewrites that +// sequence into a curly closing quote, which is how the wrong spelling got +// into four files and survived being fixed once (Copilot on #765). A caller +// copying it from prose would get a malformed form. +// +// It is an encoding applied to the whole value, not a metacharacter list. +// Hints and breadcrumbs are text a person pastes into a shell, and they +// interpolate values from configuration files and from the API — neither of +// which is held to whatever check applied when the value was first created. +// Escaping cases one at a time is how quoting bugs recur. +// +// internal/commands and internal/auth each carry a copy of this, written +// before there was a shared home for it. This is the one new code should +// use; converging those two is not this change's to do. +func ShellQuote(s string) string { + if s != "" && strings.IndexFunc(s, shellActive) < 0 { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellActive reports whether r can mean anything to a POSIX shell outside +// quotes; letters, digits and a few inert punctuation marks cannot. +func shellActive(r rune) bool { + inert := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_./:@%+=-", r) + return !inert +} diff --git a/internal/richtext/shellquote_test.go b/internal/richtext/shellquote_test.go new file mode 100644 index 000000000..9cc1bac26 --- /dev/null +++ b/internal/richtext/shellquote_test.go @@ -0,0 +1,26 @@ +package richtext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShellQuote(t *testing.T) { + for name, tc := range map[string]struct{ in, want string }{ + "inert": {"agent", "agent"}, + "inert symbols": {"a-b_c.d/e:f@g%h+i=j", "a-b_c.d/e:f@g%h+i=j"}, + "empty": {"", "''"}, + "space": {"two words", "'two words'"}, + "semicolon": {"a;rm -rf /", "'a;rm -rf /'"}, + "substitution": {"$(id)", "'$(id)'"}, + "backtick": {"`id`", "'`id`'"}, + "single quote": {"it's", `'it'\''s'`}, + "only a quote": {"'", `''\'''`}, + "quote and semi": {"'; id; '", `''\''; id; '\'''`}, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, ShellQuote(tc.in)) + }) + } +} diff --git a/internal/richtext/shellquote_unix_test.go b/internal/richtext/shellquote_unix_test.go new file mode 100644 index 000000000..467e2affe --- /dev/null +++ b/internal/richtext/shellquote_unix_test.go @@ -0,0 +1,41 @@ +//go:build unix + +package richtext + +import ( + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These hand the encoding to a real shell, which is the only thing that +// settles whether it is right. /bin/sh is not there on Windows, which this +// repository builds for, so they live behind the unix tag and the pure +// encoding tests next door stay portable (Copilot on #765). + +// The encoding is only worth anything if a real shell reads it back as the +// one word that went in — an embedded single quote being the case that +// tells quoting apart from wrapping. +func TestShellQuoteSurvivesARealShell(t *testing.T) { + for _, in := range []string{ + "agent", "", "two words", "a;rm -rf /", "$(echo pwned)", "`echo pwned`", + "it's", "'", "'; echo pwned; '", "a\nb", `back\slash`, "*", "~root", + } { + out, err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "printf %s "+ShellQuote(in)).Output() + require.NoError(t, err, "input %q quoted as %s", in, ShellQuote(in)) + assert.Equal(t, in, string(out), "input %q quoted as %s", in, ShellQuote(in)) + } +} + +// And the word count is one: a value with a space in it must not split into +// two arguments. +func TestShellQuoteKeepsAValueOneWord(t *testing.T) { + for _, in := range []string{"two words", "'; echo pwned; '", "a b c"} { + out, err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "set -- "+ShellQuote(in)+"; echo $#").Output() + require.NoError(t, err) + assert.Equal(t, "1", strings.TrimSpace(string(out)), "input %q", in) + } +} diff --git a/skill-evals/cases/basecamp-connect/first-time-setup.yml b/skill-evals/cases/basecamp-connect/first-time-setup.yml index 0f0f1f7e3..5fbe18f4e 100644 --- a/skill-evals/cases/basecamp-connect/first-time-setup.yml +++ b/skill-evals/cases/basecamp-connect/first-time-setup.yml @@ -5,10 +5,8 @@ context: >- Everything you need is here, so don't stop to ask: the agent's profile should be called helper, and the agent is called Helper: if the credential turns out to be Helper, I confirm that is the agent. My own profile is jorge. - Only I should be able to drive the agent. Redesign's work lives in - /home/me/Work/redesign, which exists and is a git repository. When a - connection link and code are printed I open the link and approve the - connection. + Only I should be able to drive the agent. When a connection link and code + are printed I open the link and approve the connection. tags: [connect] mocks: - match: 'auth status.*jorge' @@ -16,7 +14,7 @@ mocks: - match: '^me .*jorge' output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - match: 'connect show' - output: '{"ok":false,"code":"not_found","error":"connect.json for profile not found: helper","hint":"The profile has not been set up. Set it up: basecamp connect setup -P helper --operator-profile --route ="}' + output: '{"ok":false,"code":"not_found","error":"connect.json for profile not found: helper","hint":"The profile has not been set up. Set it up: basecamp connect setup -P helper --operator-profile --serve "}' - match: 'auth status' output: '{"ok":false,"code":"api_error","error":"unknown profile \"helper\" (available: jorge)"}' - match: 'profile list' @@ -27,10 +25,10 @@ mocks: output: '{"ok":true,"data":{"identity":{"id":7001,"first_name":"Helper","last_name":"","email_address":""},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":4001,"name":"Helper"}}}' - match: 'projects list' output: '{"ok":true,"data":[{"id":111,"name":"Marketing"},{"id":222,"name":"Redesign"},{"id":333,"name":"Redesign Archive"}],"summary":"3 projects"}' - - match: 'connect setup(?!.*--route)' + - match: 'connect setup(?!.*--serve)' output: '{"ok":false,"code":"usage","error":"Setup needs to know who the operator is","hint":"Pass --operator-profile (or --operator ). The operator is the person the agent takes instructions from, and is never guessed."}' - match: 'connect setup' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4001,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","routes":1,"written":true,"ready":true,"checks":[{"name":"Token","status":"pass","message":"The profile''s credential yields a token"},{"name":"Identity","status":"pass","message":"Agent person 4001"},{"name":"Scope","status":"pass","message":"Full access: the agent can reply and acknowledge"},{"name":"Operator","status":"pass","message":"Person 1001, Jorge, the identity of profile \"jorge\""},{"name":"Stream ticket","status":"pass","message":"The agent can mint a ticket for the account event feed"},{"name":"Project 222","status":"pass","message":"Readable by the agent, routed to /home/me/Work/redesign"}]},"summary":"connect.json written; 6 passed"}' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4001,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","projects":1,"written":true,"ready":true,"checks":[{"name":"Token","status":"pass","message":"The profile''s credential yields a token"},{"name":"Identity","status":"pass","message":"Agent person 4001"},{"name":"Scope","status":"pass","message":"Full access: the agent can reply and acknowledge"},{"name":"Operator","status":"pass","message":"Person 1001, Jorge, the identity of profile \"jorge\""},{"name":"Stream ticket","status":"pass","message":"The agent can mint a ticket for the account event feed"},{"name":"Project 222","status":"pass","message":"Readable by the agent"}]},"summary":"connect.json written; 6 passed"}' expect_sequence: - match: 'auth agent connect .*(-P|--profile)[ =]''?helper\b' - match: '^me .*(-P|--profile)[ =]''?helper\b' @@ -38,14 +36,61 @@ expect_sequence: accept: - 'connect setup .*(-P|--profile)[ =]''?helper\b' - 'connect setup .*--operator-profile[ =]''?jorge\b' - - 'connect setup .*--route[ =]''?222=''?/home/me/Work/redesign\b' + - 'connect setup .*--serve[ =](''222''|"222"|222)(\s|$)' reject: + # What these evals guarantee about setup commands: every one in the trace + # is a command the CLI would accept. The accepts above prove a correct one + # was issued; these prove no incorrect one was, which is the half a model + # can otherwise satisfy by issuing a command the CLI refuses and then + # retrying — the broad setup mock answers success either way. + # + # --serve carries a project id and nothing else: digits ending the shell + # word, bare or quoted either way, naming something above zero. Written as the + # shape of a wrong value rather than as a list of wrong values, because + # four rounds of review found four more spellings — and without lookahead, + # so Go's RE2 reads the same pattern the Ruby runner does. + # + # Deliberately narrower than parsePositiveID in two places, both of which + # the guard in internal/commands/connect_skilleval_test.go asserts rather + # than leaves implicit: a leading plus (+222 parses to 222) and fragments + # the shell would join into one word ('22''2' is 222). A trace may use + # neither. That guard holds both directions — everything these patterns + # allow, the CLI must accept; everything the CLI refuses, they must reject + # — and its doc comment names what it cannot model, which is the shell + # itself. + - 'connect setup .*--serve[ =]([0-9]*[^0-9\s''"]|0+(\s|$)|''[0-9]*[^0-9'']|''0*''|''[0-9]*''[^\s]|"[0-9]*[^0-9"]|"0*"|"[0-9]*"[^\s]|[0-9]+[''"])' + - 'connect setup .*--serve=(\s|$)' + # And --serve with no value at all, which cobra refuses outright. + - 'connect setup .*--serve\s*$' + # A value of all digits can still be too large for an int64, which no + # shape can see. Capped at 18 digits, comfortably above any real + # project id and safely below the 19 of MaxInt64 — so a trace may not + # carry 9223372036854775807 either, which the CLI would take. That + # narrowing is declared in the guard's corpus rather than left here. + - 'connect setup .*--serve[ =][''"]?[0-9]{19}' + # Form is not identity. Every rule above asks whether a --serve value is a + # value the CLI would take; none asks whether it is the project this case + # is about, so a trace could serve 2223, or 111, and then serve 222 and + # pass — the broad setup mock reports success for both (Copilot on #765). + # This is the last axis of these patterns that nothing was checking. + # + # A well-formed id that is not 222: shorter, longer, or three digits + # differing in one place. Written out rather than as a negative lookahead, + # which RE2 does not have, so Go's guard reads the same pattern the Ruby + # runner does. + - 'connect setup .*--serve[ =][''"]?([0-9]{1,2}|[0-9]{4,}|[013-9][0-9]{2}|[0-9][013-9][0-9]|[0-9][0-9][013-9])[''"]?(\s|$)' + # And the flag that no longer exists, in any spelling. + - 'connect setup .*--(route|remove-route)\b' + # --unserve has no place in either of these scenarios: nothing is served + # yet to withdraw. Paired with --serve in one run it is worse than useless, + # because setup.Apply refuses the run outright ("project %d is both served + # and removed in one run"), so the agent would have done nothing at all. + - 'connect setup .*--unserve\b' - '--with-token' - '--with-client-credentials' # Every machine-output mode the interactive connection refuses. - 'auth agent connect.*(--json|--agent|--quiet|--ids-only|--count|--jq|-j\b|-q\b)' - 'connect setup .*--operator[ =]' - - 'connect setup .*--route[ =]''?333=' - 'connect setup .*--(trust[ =]''?(allowlist|project)|allow[ =])' - '--expect-identity' - 'connect (run|start|service)' diff --git a/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml b/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml index 16f554bb2..b866a88be 100644 --- a/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml +++ b/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml @@ -1,9 +1,9 @@ task: >- - Add our Redesign project to my agent's connector. Its work lives in - /home/me/Work/redesign. Tell me whether it's ready. + Add our Redesign project to my agent's connector. Tell me whether it's + ready. context: >- The agent's profile is helper, already connected and set up before with me as - the operator; my own profile is jorge. The directory exists. + the operator; my own profile is jorge. tags: [connect] mocks: - match: 'auth status.*jorge' @@ -11,7 +11,7 @@ mocks: - match: '^me .*jorge' output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - match: 'connect show' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 served project(s)"}' - match: 'auth status' output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' - match: 'profile list' @@ -22,15 +22,58 @@ mocks: output: '{"ok":false,"code":"not_ready","error":"The connector is not ready, so /home/me/.config/basecamp/connect/helper/connect.json was not written. Project 222: Reading the project was refused (HTTP 403). Basecamp refuses this read to an Agent identity today, and admission makes it for every event: the connector would see mentions and block each one on a read it cannot make","hint":"If the agent is not on this project, add it there. Otherwise: Until Basecamp allows Agent identities these reads, run the connector as a bot user: basecamp connect setup -P --expect-identity "}' accept: - 'connect setup .*(-P|--profile)[ =]''?helper\b' - - 'connect setup .*--route[ =]''?222=''?/home/me/Work/redesign\b' + - 'connect setup .*--serve[ =](''222''|"222"|222)(\s|$)' reject: + # What these evals guarantee about setup commands: every one in the trace + # is a command the CLI would accept. The accepts above prove a correct one + # was issued; these prove no incorrect one was, which is the half a model + # can otherwise satisfy by issuing a command the CLI refuses and then + # retrying — the broad setup mock answers success either way. + # + # --serve carries a project id and nothing else: digits ending the shell + # word, bare or quoted either way, naming something above zero. Written as the + # shape of a wrong value rather than as a list of wrong values, because + # four rounds of review found four more spellings — and without lookahead, + # so Go's RE2 reads the same pattern the Ruby runner does. + # + # Deliberately narrower than parsePositiveID in two places, both of which + # the guard in internal/commands/connect_skilleval_test.go asserts rather + # than leaves implicit: a leading plus (+222 parses to 222) and fragments + # the shell would join into one word ('22''2' is 222). A trace may use + # neither. That guard holds both directions — everything these patterns + # allow, the CLI must accept; everything the CLI refuses, they must reject + # — and its doc comment names what it cannot model, which is the shell + # itself. + - 'connect setup .*--serve[ =]([0-9]*[^0-9\s''"]|0+(\s|$)|''[0-9]*[^0-9'']|''0*''|''[0-9]*''[^\s]|"[0-9]*[^0-9"]|"0*"|"[0-9]*"[^\s]|[0-9]+[''"])' + - 'connect setup .*--serve=(\s|$)' + # And --serve with no value at all, which cobra refuses outright. + - 'connect setup .*--serve\s*$' + # A value of all digits can still be too large for an int64, which no + # shape can see. Capped at 18 digits, comfortably above any real + # project id and safely below the 19 of MaxInt64 — so a trace may not + # carry 9223372036854775807 either, which the CLI would take. That + # narrowing is declared in the guard's corpus rather than left here. + - 'connect setup .*--serve[ =][''"]?[0-9]{19}' + # Form is not identity. Every rule above asks whether a --serve value is a + # value the CLI would take; none asks whether it is the project this case + # is about, so a trace could serve 2223, or 111, and then serve 222 and + # pass — the broad setup mock reports success for both (Copilot on #765). + # This is the last axis of these patterns that nothing was checking. + # + # A well-formed id that is not 222: shorter, longer, or three digits + # differing in one place. Written out rather than as a negative lookahead, + # which RE2 does not have, so Go's guard reads the same pattern the Ruby + # runner does. + - 'connect setup .*--serve[ =][''"]?([0-9]{1,2}|[0-9]{4,}|[013-9][0-9]{2}|[0-9][013-9][0-9]|[0-9][0-9][013-9])[''"]?(\s|$)' + # And the flag that no longer exists, in any spelling. + - 'connect setup .*--(route|remove-route)\b' - 'auth agent connect' - 'auth logout' - 'auth login' - 'profile create' - - '--remove-route' + - '--unserve' - '--with-token' accept_response: - '(?i)bot[- ]user' - '(?i)(member|add(ed)? (the agent )?to|on the project|in the project)' - - '(?i)(not|n.t)\W+(be(en)?\W+)?(written|write|changed|saved|added|add|routed)\b|nothing\W+(was\W+)?(written|changed)|unchanged' + - '(?i)(not|n.t)\W+(be(en)?\W+)?(written|write|changed|saved|added|add|served)\b|nothing\W+(was\W+)?(written|changed)|unchanged' diff --git a/skill-evals/cases/basecamp-connect/route-quoting.yml b/skill-evals/cases/basecamp-connect/route-quoting.yml deleted file mode 100644 index df7033305..000000000 --- a/skill-evals/cases/basecamp-connect/route-quoting.yml +++ /dev/null @@ -1,32 +0,0 @@ -task: >- - Add our project called "Launch $(date)" to my agent's connector. Its work - lives in /home/me/Work/Q3 $launch. -context: >- - The agent's profile is helper; it is already connected, its identity is - confirmed, and it was set up before with me as the operator (my profile is - jorge). The directory exists. Don't stop to ask. -tags: [connect] -mocks: - - match: 'auth status.*jorge' - output: '{"ok":true,"data":{"profile":"jorge","authenticated":true,"oauth_type":"launchpad","account_id":"999","storage":"keyring","user_email":"jorge@example.com"}}' - - match: '^me .*jorge' - output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - - match: 'connect show' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' - - match: 'auth status' - output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' - - match: 'projects list' - output: '{"ok":true,"data":[{"id":111,"name":"Marketing"},{"id":222,"name":"Launch $(date)"}],"summary":"2 projects"}' - - match: 'connect setup' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4001,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","routes":2,"written":true,"ready":true,"checks":[{"name":"Project 222","status":"pass","message":"Readable by the agent, routed to /home/me/Work/Q3 $launch"}]},"summary":"connect.json written; all passed"}' -accept: - - 'connect setup .*(-P|--profile)[ =]''?helper\b' - - 'connect setup .*--route[ =](''222=/home/me/Work/Q3 \$launch''|222=''/home/me/Work/Q3 \$launch'')' -reject: - # The name never reaches a command; only its id does. - - '\$\(date\)' - - 'Launch' - # Unquoted, or double-quoted (where $launch would still expand). - - '--route[ =]222=/home' - - '--route[ =]"' - - '222="' diff --git a/skill-evals/cases/basecamp-connect/serve-by-id.yml b/skill-evals/cases/basecamp-connect/serve-by-id.yml new file mode 100644 index 000000000..7e8eecc7f --- /dev/null +++ b/skill-evals/cases/basecamp-connect/serve-by-id.yml @@ -0,0 +1,77 @@ +task: >- + Add our project called "Launch $(date)" to my agent's connector. +context: >- + The agent's profile is helper; it is already connected, its identity is + confirmed, and it was set up before with me as the operator (my profile is + jorge). Don't stop to ask. +tags: [connect] +mocks: + - match: 'auth status.*jorge' + output: '{"ok":true,"data":{"profile":"jorge","authenticated":true,"oauth_type":"launchpad","account_id":"999","storage":"keyring","user_email":"jorge@example.com"}}' + - match: '^me .*jorge' + output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' + - match: 'connect show' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 served project(s)"}' + - match: 'auth status' + output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' + - match: 'projects list' + output: '{"ok":true,"data":[{"id":111,"name":"Marketing"},{"id":222,"name":"Launch $(date)"}],"summary":"2 projects"}' + - match: 'connect setup' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4001,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","projects":2,"written":true,"ready":true,"checks":[{"name":"Project 222","status":"pass","message":"Readable by the agent"}]},"summary":"connect.json written; all passed"}' +accept: + - 'connect setup .*(-P|--profile)[ =]''?helper\b' + - 'connect setup .*--serve[ =](''222''|"222"|222)(\s|$)' +reject: + # What these evals guarantee about setup commands: every one in the trace + # is a command the CLI would accept. The accepts above prove a correct one + # was issued; these prove no incorrect one was, which is the half a model + # can otherwise satisfy by issuing a command the CLI refuses and then + # retrying — the broad setup mock answers success either way. + # + # --serve carries a project id and nothing else: digits ending the shell + # word, bare or quoted either way, naming something above zero. Written as the + # shape of a wrong value rather than as a list of wrong values, because + # four rounds of review found four more spellings — and without lookahead, + # so Go's RE2 reads the same pattern the Ruby runner does. + # + # Deliberately narrower than parsePositiveID in two places, both of which + # the guard in internal/commands/connect_skilleval_test.go asserts rather + # than leaves implicit: a leading plus (+222 parses to 222) and fragments + # the shell would join into one word ('22''2' is 222). A trace may use + # neither. That guard holds both directions — everything these patterns + # allow, the CLI must accept; everything the CLI refuses, they must reject + # — and its doc comment names what it cannot model, which is the shell + # itself. + - 'connect setup .*--serve[ =]([0-9]*[^0-9\s''"]|0+(\s|$)|''[0-9]*[^0-9'']|''0*''|''[0-9]*''[^\s]|"[0-9]*[^0-9"]|"0*"|"[0-9]*"[^\s]|[0-9]+[''"])' + - 'connect setup .*--serve=(\s|$)' + # And --serve with no value at all, which cobra refuses outright. + - 'connect setup .*--serve\s*$' + # A value of all digits can still be too large for an int64, which no + # shape can see. Capped at 18 digits, comfortably above any real + # project id and safely below the 19 of MaxInt64 — so a trace may not + # carry 9223372036854775807 either, which the CLI would take. That + # narrowing is declared in the guard's corpus rather than left here. + - 'connect setup .*--serve[ =][''"]?[0-9]{19}' + # Form is not identity. Every rule above asks whether a --serve value is a + # value the CLI would take; none asks whether it is the project this case + # is about, so a trace could serve 2223, or 111, and then serve 222 and + # pass — the broad setup mock reports success for both (Copilot on #765). + # This is the last axis of these patterns that nothing was checking. + # + # A well-formed id that is not 222: shorter, longer, or three digits + # differing in one place. Written out rather than as a negative lookahead, + # which RE2 does not have, so Go's guard reads the same pattern the Ruby + # runner does. + - 'connect setup .*--serve[ =][''"]?([0-9]{1,2}|[0-9]{4,}|[013-9][0-9]{2}|[0-9][013-9][0-9]|[0-9][0-9][013-9])[''"]?(\s|$)' + # And the flag that no longer exists, in any spelling. + - 'connect setup .*--(route|remove-route)\b' + # --unserve has no place in either of these scenarios: nothing is served + # yet to withdraw. Paired with --serve in one run it is worse than useless, + # because setup.Apply refuses the run outright ("project %d is both served + # and removed in one run"), so the agent would have done nothing at all. + - 'connect setup .*--unserve\b' + # The name never reaches a command; only its id does. A project name is the + # one value here nobody controls, and it can hold anything a shell would act + # on. + - '\$\(date\)' + - 'Launch' diff --git a/skill-evals/cases/basecamp-connect/unconfirmed-identity.yml b/skill-evals/cases/basecamp-connect/unconfirmed-identity.yml index 326738fff..e2232cc55 100644 --- a/skill-evals/cases/basecamp-connect/unconfirmed-identity.yml +++ b/skill-evals/cases/basecamp-connect/unconfirmed-identity.yml @@ -1,10 +1,9 @@ task: >- - Set up my Helper agent's connector for the Redesign project. Its work lives - in /home/me/Work/redesign. + Set up my Helper agent's connector for the Redesign project. context: >- The agent is called Helper. Its profile is helper and already holds a - credential; it has not been set up yet. My own profile is jorge, and only I should drive it. The - directory exists. + credential; it has not been set up yet. My own profile is jorge, and only I + should drive it. tags: [connect] mocks: - match: 'auth status.*jorge' @@ -12,7 +11,7 @@ mocks: - match: '^me .*jorge' output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - match: 'connect show' - output: '{"ok":false,"code":"not_found","error":"connect.json for profile not found: helper","hint":"The profile has not been set up. Set it up: basecamp connect setup -P helper --operator-profile --route ="}' + output: '{"ok":false,"code":"not_found","error":"connect.json for profile not found: helper","hint":"The profile has not been set up. Set it up: basecamp connect setup -P helper --operator-profile --serve "}' - match: 'auth status' output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' - match: 'profile list' @@ -22,7 +21,7 @@ mocks: - match: 'projects list' output: '{"ok":true,"data":[{"id":111,"name":"Marketing"},{"id":222,"name":"Redesign"}],"summary":"2 projects"}' - match: 'connect setup' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4002,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","routes":1,"written":true,"ready":true,"checks":[]},"summary":"connect.json written"}' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","profile":"helper","account_id":"999","agent_person_id":4002,"agent_kind":"agent","operator_id":1001,"trust_mode":"operator","projects":1,"written":true,"ready":true,"checks":[]},"summary":"connect.json written"}' accept: - '^me .*(-P|--profile)[ =]''?helper\b' reject: diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 23fc326fc..7308ffecb 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -3,8 +3,8 @@ name: basecamp-connect description: | Connect a Basecamp agent to this computer and manage the local agent connector's setup: the agent's credential (basecamp auth agent connect), - connect.json (who may drive the agent, which project routes to which - directory), and readiness (basecamp connect setup). Explains every setup + connect.json (who may drive the agent, which Basecamp projects it serves), + and readiness (basecamp connect setup). Explains every setup result and failure. Also reads what the connector ran (status, doctor) and carries out a person's decisions on its records (redispatch, discard, release, the cutover's shadow promote and import). Starting and supervising @@ -20,7 +20,7 @@ triggers: - basecamp connect setup - basecamp auth agent connect - connect.json - - route a project to a directory + - serve a project - who can drive the agent - connector not ready - basecamp connect status @@ -34,8 +34,10 @@ triggers: The local agent connector lets people in Basecamp hand work to a coding agent on this computer. It listens to the account's event feed **as a Basecamp agent**, -admits what a trusted person asks of that agent, and runs the work in the local -directory the project is routed to. The agent replies in Basecamp as itself. +admits what a trusted person asks of that agent, and runs the work in the +directory the connector itself was started in. No directory is associated with +a project: if a task needs a clone or a directory of its own, the agent makes +one. The agent replies in Basecamp as itself. You manage it for the person. They should never have to type a command: you check what is there, ask what you need in plain words, run the commands, and @@ -46,7 +48,7 @@ explain the result. This skill is the reference you do that from. | Command | Owns | Run it when | |---------|------|-------------| | `basecamp auth agent connect -P ''` | The agent's **credential**, stored under a CLI profile | The profile does not exist yet, or the person agrees to replace its Agent credential | -| `basecamp connect setup -P ''` | **Policy and readiness**: connect.json and the checks | First setup after the credential, and every change to trust or routes | +| `basecamp connect setup -P ''` | **Policy and readiness**: connect.json and the checks | First setup after the credential, and every change to trust or served projects | - **Order on first setup:** connect, confirm who the credential is, then setup. Setup does not obtain a credential. @@ -96,15 +98,15 @@ the person who the credential is and let them decide. After setup, check - **Numeric ids** (project, person, account and identity ids) go in bare, as digits only. Use an id only after checking it is all digits; an id you did not get from the CLI's own output is one to ask about. -- **Every other value** goes in single quotes: profile names, directories, class - labels, anything the person typed. Write a single quote inside a value as - `'\''`. Single quotes stop `~` expanding, so write a directory as an absolute - path. Fixed words from this skill (`operator`, `spawn`, `90m`) need no quotes. +- **Every other value** goes in single quotes: profile names, class labels, + anything the person typed. Write a single quote inside a value as + `'\''`. Fixed words from this skill (`operator`, `spawn`, `90m`) need no + quotes. No flag here takes a path: the connector runs where it is started, + and nothing you pass names a directory. Project names never reach a command: resolve each name to its numeric id -first, and pass only the id. For example the directory -`/home/me/Work/Q3 $launch` for project 222 is -`--route '222=/home/me/Work/Q3 $launch'`. +first, and pass only the id. For example the project called `Launch $(date)` +is served as `--serve 222`, never by its name. **Interactive logins.** `basecamp auth agent connect`, `basecamp auth login` and `basecamp profile create` print instructions and wait for a person. Run them @@ -126,14 +128,15 @@ The CLI's configuration, its profiles and (when it uses files) its credential store also live under `$XDG_CONFIG_HOME/basecamp`, so pointing `XDG_CONFIG_HOME` somewhere else hides every profile. -connect.json holds ids, a trust mode and directory paths, and no credential. +connect.json holds ids and a trust mode, no directory and no credential. Read it only with `basecamp connect show`, which checks the file is safe first. ## connect.json -connect.json is the only authority for who may drive the agent and which -directory a project's work runs in. Nothing read from Basecamp adds a route or -widens trust. +connect.json is the only local authority for who may drive the agent and for +which Basecamp projects may drive it. Nothing read from Basecamp serves a +project or widens trust. It names no directory: the connector runs where it is +started. ```json { @@ -143,7 +146,7 @@ widens trust. "agent": { "person_id": 4001, "kind": "agent" }, "trust": { "mode": "operator", "operator_id": 1001 }, "projects": { - "222": { "path": "/home/me/Work/app", "class": "internal", "watch_completions": true } + "222": { "class": "internal", "watch_completions": true } }, "driver": "spawn", "worker": "claude", @@ -160,7 +163,7 @@ widens trust. | `trust.mode` | Who may drive the agent: `operator`, `allowlist` or `project` | `--trust` | | `trust.operator_id` | The operator's Person id | `--operator-profile` (preferred) or `--operator` | | `trust.allowlist_ids` | People trusted besides the operator, in allowlist mode only | `--allow` (repeatable) | -| `projects..path` | The directory that project's work runs in: absolute, symlinks resolved | `--route '='`, `--remove-route ` | +| `projects.` | A Basecamp project this agent serves. In one it does not serve, a trusted mention or an operator assignment gets a holding reply and no work; anything else is discarded unanswered | `--serve `, `--unserve ` | | `projects..class` | A label carried on the project's records: 1 to 40 lowercase letters, digits, `-` and `_`, starting with a letter or digit | `--class '='`; `--class '='` clears it | | `projects..watch_completions` | Every trusted completion in the project reaches the agent, without assigning it | `--watch-completions `, `--no-watch-completions ` | | `driver` | How workers are run: `spawn` (default) or `acp` | `--driver` | @@ -169,7 +172,7 @@ widens trust. | `deadline` | Time limit per task, 1m to 24h (default 45m) | `--deadline 90m` | **Never edit connect.json by hand.** It is the trust anchor: setup verifies -every person and route before writing it, writes it owner-only, and parses it +every person and project before writing it, writes it owner-only, and parses it strictly (an unknown or misspelled key, a key given twice, or a loose permission makes it refused). Every change goes through setup. @@ -220,7 +223,7 @@ could look up: `unknown profile` error (`api_error`) means no such profile, which is also what every other command says about it. **Never read connect.json directly** (no `cat`, no file read): that skips those checks. - To tell the person which projects are routed, look each id up under the + To tell the person which projects are served, look each id up under the agent's profile (`basecamp projects show -P '' --json`); if that is refused, use the operator's profile. Say names, not ids. 5. **Readiness,** once the profile is set up: @@ -237,9 +240,9 @@ Work through these in order, asking only what you cannot find out. and `_`, starting with a letter or digit, for example the agent's name. Inspect it, all five steps above. If `basecamp connect show` prints a policy, the profile is already set up: say what it holds — the operator, the trust mode -and each routed project — and go to Changing the setup later instead. Setup -keeps everything you do not pass, so adding a route to a profile you have not -looked at leaves trust and routes in place that nobody mentioned. +and each served project — and go to Changing the setup later instead. Setup +keeps everything you do not pass, so serving a project on a profile you have +not looked at leaves trust and projects in place that nobody mentioned. - **The profile does not exist, Agent person** (the normal path): run `basecamp auth agent connect -P ''` as described under Interactive @@ -281,8 +284,11 @@ pass `--allow ` for each. project it works in. - Show the names, let the person choose, and map each choice to its numeric `id` yourself. When a name matches more than one project, ask which. -- For each project ask which local directory its work runs in. Check the - directory exists. +- Do not ask where a project's work lives. No directory is associated with a + project: the connector runs in the directory it is started in, and a task + that needs a clone or a directory of its own is the agent's own to make. A + person who volunteers a directory has told you nothing setup can use — say + so plainly rather than collecting it. - Offer `--watch-completions` only when the person wants the agent to act on every completed to-do or card in a project without being assigned. Offer `--class` only when they want projects labelled (for example `internal`; see @@ -291,12 +297,12 @@ pass `--allow ` for each. asked. **5. Confirm, then run setup.** Say back in plain words: the agent, the -operator, the trust mode, and each project name with its directory. Then run, -quoting values by the Shell quoting rule: +operator, the trust mode, and each project name. Then run, quoting values by +the Shell quoting rule: ```bash basecamp connect setup -P '' --operator-profile '' \ - --route '=' --route '=' --json + --serve --serve --json ``` adding `--trust`, `--allow`, `--watch-completions` or `--class` @@ -313,8 +319,8 @@ project names up the same way as on first setup, and quote values by the Shell q | To | Run | |----|-----| -| Add a project, or move it to another directory | `basecamp connect setup -P '' --route '=' --json` | -| Remove a project | `basecamp connect setup -P '' --remove-route --json` | +| Serve a project | `basecamp connect setup -P '' --serve --json` | +| Stop serving a project | `basecamp connect setup -P '' --unserve --json` | | Watch, or stop watching, a project's completions | `--watch-completions ` / `--no-watch-completions ` | | Label a project, or clear its label | `--class '='` / `--class '='` | | Trust only the operator, or project members | `--trust operator` / `--trust project` (leaving allowlist mode drops the list) | @@ -323,23 +329,33 @@ project names up the same way as on first setup, and quote values by the Shell q | Change workers | `--driver`, `--worker claude` / `--worker codex`, `--concurrency`, `--deadline` | | Replace the agent's credential (only with the person's consent: it rotates the secret) | `basecamp auth agent connect -P ''`, then setup with no flags to re-check | -A class or watch setting needs the project routed first, in the same run or an -earlier one. A project cannot be routed and removed in one run. The last route -cannot be removed on its own: with no routes the connector is not ready, so -setup writes nothing. Say so, and ask what the person wants instead. +A class or watch setting needs the project served first, in the same run or an +earlier one. A project cannot be served and removed in one run. + +**Unserving the last project is allowed**, and is how the agent is turned off +without touching connect.json by hand: `--unserve ` on the only served +project writes an empty list, and setup reports a warning rather than an +error — the connector will start and do nothing. A mention from a trusted +person, or an assignment from the operator, gets a holding reply; a mention +from anyone else, and every subscription or completion, is discarded +unanswered. Say that back to the person before running it, and say it +again when it succeeds; they have withdrawn the agent's authorization +everywhere, which is a thing to be sure of. Serving one again is +`--serve `. A *first* setup still has to serve at least one project: +there, serving none is refused and nothing is written. Some changes setup refuses on purpose, because connect.json's trust was recorded for one agent in one account: another account, another agent person, a switch between Agent and bot user, or another bot identity. Each refusal names connect.json. The way through is to remove that file and set the profile up -afresh, which drops every route and trust setting. Remove it only after the +afresh, which drops every served project and trust setting. Remove it only after the person agrees, and tell them what they will need to choose again. ## Reading setup's result With `--json`, success is `{"ok": true, "data": {...}, "summary": ...}` with `data.ready` true, `data.written` true, the file `path`, `agent_person_id`, -`agent_kind`, `operator_id`, `trust_mode`, `routes` (a count) and `checks` +`agent_kind`, `operator_id`, `trust_mode`, `projects` (a count) and `checks` (each `name`, `status`, `message`, sometimes `hint`). A `warn` check is usable; mention it. @@ -351,7 +367,7 @@ the exit status: exit 7 is shared. | `code` (exit) | Means | Next step | |---------------|-------|-----------| -| `usage` (1) | Input refused: a bad flag value, no operator on a first setup, a missing directory, a class or watch setting on an unrouted project, `--expect-identity` on an Agent credential, a person refused by trust (an Agent, a client, the agent itself, or unreadable), or connect.json itself unusable | Fix the input the message names and run again. | +| `usage` (1) | Input refused: a bad flag value, no operator on a first setup, a class or watch setting on a project that is not served, `--expect-identity` on an Agent credential, a person refused by trust (an Agent, a client, the agent itself, or unreadable), or connect.json itself unusable | Fix the input the message names and run again. | | `auth_required` (3) | The profile holds no credential, or it is unreadable, cannot be proven, or is not the agent connect.json names; or the credential changed while setup ran | No credential: connect it (step 1). Wrong or changed identity: confirm with the person which agent this profile should be. Changed mid-run: run setup again. | | `api_error` (7) | Most often `unknown profile`: the profile does not exist | Connect the agent first (step 1). | | `not_ready` (7) | A readiness check failed. `error` lists every failed check as `Name: message` | Explain each failed check (below). | @@ -362,14 +378,15 @@ A setup the person stops also writes nothing. ### Failed readiness checks -Every run checks every route, including the ones it keeps. A kept route that -fails blocks the whole write, so fix or remove it before other changes can land. +Every run checks every served project, including the ones it keeps. A kept +project that fails blocks the whole write, so fix it or stop serving it before +other changes can land. -- **Routes: No project is routed.** Every mention would get a holding reply and - no work. Add a project (step 4). -- **Project ``: the route's directory is no longer usable.** The directory - was moved or deleted. Ask where the project's work lives now and route it - again, or remove the route. +- **Projects: No project is served.** A *failure* only on a first setup, where + it means the profile has not been set up: serve a project (step 4). On a + profile already set up it is a *warning*, not a failure — connect.json is + written, and the agent is left doing nothing until a project is served + again. - **Project ``: reading the project was refused, and the message says Basecamp refuses this read to an Agent identity today.** This is Basecamp, not the setup: an Agent identity is refused the project and people reads @@ -377,7 +394,7 @@ fails blocks the whole write, so fix or remove it before other changes can land. never act on them. First check the agent is a member of the project. If it is, the way to run today is the **bot-user path**: sign a bot user in under a profile of its own (step 1, Bot user) and set that profile up - (`basecamp connect setup -P '' --operator-profile '' --expect-identity --route '='`). + (`basecamp connect setup -P '' --operator-profile '' --expect-identity --serve `). The Agent profile's credential stays as it is. Explain this and let the person decide before starting a bot-user sign-in: it needs a bot user account and its identity id. diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 04bb5c0ef..e3927e77c 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1453,7 +1453,7 @@ BASECAMP_NONINTERACTIVE=1 basecamp auth login --device-code # The only OAuth lo basecamp auth login --with-token -P bot --account # Import a personal access token from stdin (pipe it in) basecamp auth login --with-client-credentials --client-id -P agent --account # Authenticate as a Basecamp agent: client secret on stdin, self-token minted on demand (no refresh token) basecamp auth agent connect -P agent # Connect this computer to a Basecamp agent: approve it in a browser and its OAuth client is stored — nothing to paste -basecamp connect setup -P agent --operator-profile --route = # Set up a local agent connector on a connected profile (run `auth agent connect` first): verifies trust, checks token, identity, scope, ticket mint and project reads, then writes connect.json +basecamp connect setup -P agent --operator-profile --serve # Set up a local agent connector on a connected profile (run `auth agent connect` first): verifies trust, checks token, identity, scope, ticket mint and project reads, then writes connect.json basecamp connect -P agent # Run the connector in the foreground: hear the agent's events, admit what a trusted person asks, and hand the work to a local coding agent that replies as the agent basecamp connect -P agent --project --shadow # Narrow it to one project, and watch without acting: an isolated state directory, nothing dispatched and nothing posted basecamp connect setup -P agent --worker codex # Run workers with Codex instead of Claude Code @@ -1469,10 +1469,10 @@ there. It refuses a second connector for the same agent, and takes `--project` (repeatable) to hear and dispatch only those projects. Run it under a supervisor rather than from a session you will close. -A task runs in the directory its project is routed to, and the connector -prepares nothing: it makes no directory, no clone and no branch. Work that -needs one of its own is the agent's to make, from its own skills and -instructions. +A task runs in the directory the connector itself was started in, and the +connector prepares nothing: no directory is associated with a project, and it +makes no directory, no clone and no branch. Work that needs one of its own is +the agent's to make, from its own skills and instructions. **Before running ANY of the logins above, check `oauth_type`.** `basecamp auth status --json` reports it, and `agent` means the profile is a Basecamp agent: a