From fd1249e7785ad4bcafcfd10bf21ad4470a74fc8b Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 28 Aug 2026 12:58:44 -0500 Subject: [PATCH 1/2] Don't route a task whose project already pins a profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routeTask only checked the per-task config dir, so a project that names its own was silently overridden. That is worse than untidy: a config dir carries the account's MCP connectors and their OAuth logins, and those logins are per-profile keychain entries that cannot be shared. An influencekit task routed onto a personal profile does not error — it runs without the Linear/InfluenceKit servers it needs and the agent works around the gap, which is how we got executors hacking the DB when taskyou's own MCP went missing. Syncing the definitions across profiles would not have helped, since the credentials do not travel and a defined-but-unauthenticated server is no better than a missing one. So the project's choice is the lever: pinning a config dir is now also how a project opts out of routing. Co-Authored-By: Claude Opus 5 --- docs/plugins.md | 6 ++++-- internal/executor/routing.go | 24 +++++++++++++++++++++ internal/executor/routing_test.go | 36 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 4c81b1d1..6c815b86 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -174,8 +174,10 @@ Unrecognized lines are ignored. Timeout is 15s. Plugins are consulted in name order and the first non-empty decision wins. Failing is safe: no router, a script that errors or prints nothing, or a timeout -all spawn the task exactly as it would have. A config dir already set by hand or -by a workflow step is never overruled, and a routed task keeps its profile on +all spawn the task exactly as it would have. A config dir already set by hand, by +a workflow step, or on the **project** is never overruled — pinning a project's +config dir is how you opt it out of routing, which matters because a config dir +carries that account's MCP connectors and their per-profile OAuth logins, and a routed task keeps its profile on resume (its Claude session lives in that config dir). `HOLD` leaves a task **queued**, never blocked, and is ignored for a manually started task. Only Claude tasks are routed — `CLAUDE_CONFIG_DIR` means nothing to the other diff --git a/internal/executor/routing.go b/internal/executor/routing.go index 33947d0e..18f73c24 100644 --- a/internal/executor/routing.go +++ b/internal/executor/routing.go @@ -54,6 +54,16 @@ func (e *Executor) routeTask(ctx context.Context, task *db.Task, allowHold bool) if strings.TrimSpace(task.ClaudeConfigDir) != "" { return true } + // A project that names its own config dir has already chosen a profile, and + // that choice is load-bearing: a config dir carries the account's MCP + // connectors and their OAuth logins, which are per-profile and cannot be + // shared. Routing an influencekit task onto a personal profile doesn't error + // — it silently runs without the Linear/InfluenceKit servers it needs, and + // the agent works around the gap. So pinning a project is also how you opt it + // out of routing. + if project := e.projectConfigDir(task.Project); project != "" { + return true + } if !e.hooks.HandlesRoute() { return true } @@ -111,3 +121,17 @@ func (e *Executor) noteRouteHold(task *db.Task, decision hooks.RouteDecision) { routeHoldLog.Store(task.ID, reason) e.logLine(task.ID, "system", fmt.Sprintf("Waiting to start — %s (plugin %q). Will retry automatically.", reason, decision.Plugin)) } + +// projectConfigDir returns the config dir a project pins, or "" when it uses the +// default. Errors read as "not pinned": the caller's next step is to route, and +// a DB hiccup shouldn't be the thing that decides a profile. +func (e *Executor) projectConfigDir(project string) string { + if project == "" { + return "" + } + p, err := e.db.GetProjectByName(project) + if err != nil || p == nil { + return "" + } + return strings.TrimSpace(p.ClaudeConfigDir) +} diff --git a/internal/executor/routing_test.go b/internal/executor/routing_test.go index 1039ed81..bdd579d8 100644 --- a/internal/executor/routing_test.go +++ b/internal/executor/routing_test.go @@ -115,6 +115,42 @@ func TestRouteTask_ExplicitConfigDirIsNotOverridden(t *testing.T) { } } +func TestRouteTask_ProjectPinnedProfileIsNotOverridden(t *testing.T) { + // A project's config dir is a real choice, not a default: it carries that + // account's MCP connectors and their per-profile OAuth logins. Routing an + // influencekit task onto a personal profile wouldn't error, it would just + // silently run without the servers it needs. + e, database := newRoutingExecutor(t, "#!/bin/sh\necho CLAUDE_CONFIG_DIR=/tmp/router-choice\n") + if err := database.CreateProject(&db.Project{Name: "pinned", Path: "/tmp/pinned", ClaudeConfigDir: "~/.claude-work"}); err != nil { + t.Fatal(err) + } + task := &db.Task{Title: "t", Type: "task", Project: "pinned", Executor: db.ExecutorClaude} + if err := database.CreateTask(task); err != nil { + t.Fatal(err) + } + + if ok := e.routeTask(context.Background(), task, true); !ok { + t.Fatal("routeTask returned false") + } + if task.ClaudeConfigDir != "" { + t.Errorf("router overrode a project-pinned profile: %q", task.ClaudeConfigDir) + } +} + +func TestRouteTask_UnpinnedProjectStillRoutes(t *testing.T) { + // The flip side: pinning is opt-out, so a project that hasn't chosen still + // gets routed. Otherwise the fix above would quietly disable the feature. + e, database := newRoutingExecutor(t, "#!/bin/sh\necho CLAUDE_CONFIG_DIR=/tmp/router-choice\n") + task := newRoutingTask(t, database, db.ExecutorClaude) // project "test", no config dir + + if ok := e.routeTask(context.Background(), task, true); !ok { + t.Fatal("routeTask returned false") + } + if task.ClaudeConfigDir != "/tmp/router-choice" { + t.Errorf("unpinned project was not routed: %q", task.ClaudeConfigDir) + } +} + func TestRouteTask_ResumedTaskStaysOnItsProfile(t *testing.T) { // Session affinity, and it is not optional: a Claude session lives inside // one config dir, so a task resumed under a different profile would find no From 6acfb1795a95ed721bf5a6d3db369bda3da1f981 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 28 Aug 2026 14:43:58 -0500 Subject: [PATCH 2/2] Bump golang.org/x/crypto to v0.55.0 for GO-2026-6303 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit govulncheck flags this one as reachable, not merely present: the trace is internal/server -> charmbracelet/ssh -> golang.org/x/crypto/ssh, which is ty's own SSH server. The advisory covers callbacks in that package. Pulled forward x/sys, x/term and x/text as go mod tidy required. The CI job is continue-on-error, so this was not blocking the PR — it is here because a reachable CVE in the SSH server we expose is worth fixing when we notice it, not when something forces us to. Co-Authored-By: Claude Opus 5 --- go.mod | 10 +++++----- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 7da704bd..6611045e 100644 --- a/go.mod +++ b/go.mod @@ -66,12 +66,12 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.17 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4b1ca23d..8d5f056a 100644 --- a/go.sum +++ b/go.sum @@ -148,26 +148,26 @@ github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Br github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=