"},
+ "a file stays": {file + agent, file},
+ "self-closing": {"a" + selfClosing + "b", "ab"},
+ "self-closing, before another": {selfClosing + other, other},
+ "unclosed, before another": {unclosed + " x " + other, " x " + other},
+ "every occurrence": {agent + " and " + agent, " and "},
+ "no attachments": {"
plain
", "
plain
"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ assert.Equal(t, tc.want, StripMentionsOf(tc.in, adapterAgentID))
+ })
+ }
+}
diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go
new file mode 100644
index 000000000..dbba79596
--- /dev/null
+++ b/internal/mcpserver/connect.go
@@ -0,0 +1,237 @@
+package mcpserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/basecamp/mcp/catalog"
+ "github.com/basecamp/mcp/gateway"
+
+ "github.com/basecamp/basecamp-cli/internal/connector"
+)
+
+// The basecamp_connect domain: how a worker the connector started pulls its
+// instruction and reports on it, served from the connector's ledger.
+//
+// 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
+// instruction is an allowlist of fields (connector.Instruction).
+const (
+ connectDomainKey = "connect"
+ connectToolName = "basecamp_connect"
+ getDispatchAction = "get_dispatch"
+ ackDispatchAction = "ack_dispatch"
+ completeDispatch = "complete_dispatch"
+ connectDomainBlurb = "Your dispatch from the Basecamp agent connector: pull the instruction you were started for, acknowledge it, and report its outcome. Bound to this task; there is no listing."
+)
+
+// Dispatch is the task-bound ledger the connect domain serves.
+// *connector.TaskDispatch satisfies it.
+type Dispatch interface {
+ Get(ctx context.Context, eventID int64) (connector.Instruction, bool, error)
+ Ack(ctx context.Context, eventID int64, ackID *int64) (connector.Receipt, error)
+ Complete(ctx context.Context, eventID int64, c connector.Completion) (connector.Receipt, error)
+}
+
+var _ Dispatch = (*connector.TaskDispatch)(nil)
+
+func connectDomain() *catalog.Domain {
+ object := func(required []any, properties map[string]any) map[string]any {
+ body := map[string]any{"type": "object", "additionalProperties": false, "properties": properties}
+ if len(required) > 0 {
+ body["required"] = required
+ }
+ return body
+ }
+ eventID := func(description string) map[string]any { return idSchema(description) }
+ return &catalog.Domain{
+ Key: connectDomainKey,
+ Tool: connectToolName,
+ Blurb: connectDomainBlurb,
+ Operations: []*catalog.Operation{
+ {
+ ID: "AckDispatch",
+ Action: ackDispatchAction,
+ Tag: "Connect",
+ Summary: syntheticSummaryTag + "acknowledge an instruction you were handed. Marks it delivered and records your own acknowledgement (the boost or comment you posted). " +
+ "Safe to retry: a repeat answers the same receipt.",
+ Idempotent: true,
+ BodyRequired: true,
+ Body: object([]any{"event_id"}, map[string]any{
+ "event_id": eventID("The event get_dispatch returned."),
+ "ack_id": idSchema("The id of the boost or comment you acknowledged with, when you posted one."),
+ }),
+ },
+ {
+ ID: "CompleteDispatch",
+ Action: completeDispatch,
+ Tag: "Connect",
+ Summary: syntheticSummaryTag + "report the outcome of an instruction: succeeded or failed, with your reply's id and any links (a pull request, a card). Also acknowledges it. " +
+ "A repeat of the same report answers the same receipt; a different report is refused, because a reported outcome stands.",
+ Idempotent: true,
+ BodyRequired: true,
+ Body: object([]any{"event_id", "outcome"}, map[string]any{
+ "event_id": eventID("The event get_dispatch returned."),
+ "outcome": map[string]any{"type": "string", "enum": []any{"succeeded", "failed"}},
+ "links": map[string]any{
+ "type": "array", "maxItems": 20,
+ "items": map[string]any{"type": "string", "maxLength": 2048},
+ "description": "http(s) URLs to what the work produced.",
+ },
+ "reply_id": idSchema("The id of the comment or chat line you replied with."),
+ }),
+ },
+ {
+ ID: "GetDispatch",
+ Action: getDispatchAction,
+ Tag: "Connect",
+ Summary: syntheticSummaryTag + "the instruction for an event on your task, or the earliest you have not acknowledged. " +
+ "Returns the recording, where to reply, who asked, the instruction's content with your own mention removed, whether to acknowledge, and whether the connector already acknowledged for you. " +
+ "Calling it records that you were handed the instruction; it does not acknowledge. A repeat returns the same instruction.",
+ Idempotent: true,
+ Body: object(nil, map[string]any{
+ "event_id": eventID("An event on your task. Omit for the earliest not yet acknowledged."),
+ }),
+ },
+ },
+ }
+}
+
+// connectHandler serves one connect action.
+type connectHandler func(ctx context.Context, d Dispatch, params map[string]any) (*mcp.CallToolResult, error)
+
+var connectHandlers = map[string]connectHandler{
+ getDispatchAction: handleGetDispatch,
+ ackDispatchAction: handleAckDispatch,
+ completeDispatch: handleCompleteDispatch,
+}
+
+func (d dispatcher) handleConnect(ctx context.Context, op *catalog.Operation, params map[string]any) (*mcp.CallToolResult, error) {
+ handler, ok := connectHandlers[op.Action]
+ if !ok || d.connect == nil {
+ return gateway.ErrorResult("internal error: connect action %q is not served", op.Action), nil
+ }
+ known, _ := op.Body["properties"].(map[string]any)
+ for name := range params {
+ if _, ok := known[name]; !ok {
+ return gateway.ErrorResult("unknown parameter %q for action %q (describe the action for its schema)", name, op.Action), nil
+ }
+ }
+ return handler(ctx, d.connect, params)
+}
+
+func handleGetDispatch(ctx context.Context, d Dispatch, params map[string]any) (*mcp.CallToolResult, error) {
+ var eventID int64
+ if _, given := params["event_id"]; given {
+ id, err := requiredID(params, "event_id")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ eventID = id
+ }
+ instruction, ok, err := d.Get(ctx, eventID)
+ if err != nil {
+ return connectFailure(err), nil
+ }
+ if !ok {
+ return gateway.JSONResult(map[string]any{"instruction": nil, "message": "Nothing on this task is waiting to be acknowledged."})
+ }
+ return gateway.JSONResult(map[string]any{"instruction": instruction})
+}
+
+func handleAckDispatch(ctx context.Context, d Dispatch, params map[string]any) (*mcp.CallToolResult, error) {
+ eventID, err := requiredID(params, "event_id")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ ackID, err := optionalID(params, "ack_id")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ receipt, err := d.Ack(ctx, eventID, ackID)
+ if err != nil {
+ return connectFailure(err), nil
+ }
+ return gateway.JSONResult(receipt)
+}
+
+func handleCompleteDispatch(ctx context.Context, d Dispatch, params map[string]any) (*mcp.CallToolResult, error) {
+ eventID, err := requiredID(params, "event_id")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ outcome, err := optionalString(params, "outcome")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ if outcome == "" {
+ return gateway.ErrorResult("missing required parameter %q (describe the action for its schema)", "outcome"), nil
+ }
+ replyID, err := optionalID(params, "reply_id")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
+ }
+ var links []string
+ if raw, ok := params["links"]; ok && raw != nil {
+ items, ok := raw.([]any)
+ if !ok {
+ return gateway.ErrorResult("parameter %q must be an array of strings", "links"), nil
+ }
+ for _, item := range items {
+ link, ok := item.(string)
+ if !ok {
+ return gateway.ErrorResult("parameter %q must be an array of strings", "links"), nil
+ }
+ links = append(links, link)
+ }
+ }
+ receipt, err := d.Complete(ctx, eventID, connector.Completion{Outcome: connector.Outcome(outcome), Links: links, ReplyID: replyID})
+ if err != nil {
+ return connectFailure(err), nil
+ }
+ return gateway.JSONResult(receipt)
+}
+
+func optionalID(params map[string]any, name string) (*int64, error) {
+ if raw, ok := params[name]; !ok || raw == nil {
+ return nil, nil
+ }
+ id, err := requiredID(params, name)
+ if err != nil {
+ return nil, err
+ }
+ return &id, nil
+}
+
+// connectFailure names a refusal by what it means to the worker. A fault the
+// worker cannot act on is reported without the ledger's own detail.
+func connectFailure(err error) *mcp.CallToolResult {
+ for _, known := range []struct {
+ err error
+ kind string
+ }{
+ {connector.ErrTaskTokenRefused, "task_token_refused"},
+ {connector.ErrNotOnTask, "not_on_task"},
+ {connector.ErrNotExposed, "not_exposed"},
+ {connector.ErrReportConflict, "report_conflict"},
+ {connector.ErrNotDispatchable, "not_dispatchable"},
+ } {
+ if errors.Is(err, known.err) {
+ result, encodeErr := gateway.JSONResult(map[string]any{"error": known.kind, "message": known.err.Error()})
+ if encodeErr != nil || result == nil {
+ return gateway.ErrorResult("%s", known.kind)
+ }
+ result.IsError = true
+ return result
+ }
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return gateway.ErrorResult("the call was canceled")
+ }
+ return gateway.ErrorResult("%s", fmt.Sprint(err))
+}
diff --git a/internal/mcpserver/connect_test.go b/internal/mcpserver/connect_test.go
new file mode 100644
index 000000000..38834d5ae
--- /dev/null
+++ b/internal/mcpserver/connect_test.go
@@ -0,0 +1,196 @@
+package mcpserver
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/basecamp/mcp/mcptest"
+
+ "github.com/basecamp/basecamp-cli/internal/connector"
+)
+
+// fakeDispatch records what the domain asked of the ledger.
+type fakeDispatch struct {
+ getIDs []int64
+ acks []*int64
+ completes []connector.Completion
+ err error
+ none bool
+}
+
+func (f *fakeDispatch) Get(_ context.Context, eventID int64) (connector.Instruction, bool, error) {
+ f.getIDs = append(f.getIDs, eventID)
+ if f.err != nil || f.none {
+ return connector.Instruction{}, false, f.err
+ }
+ return connector.Instruction{EventID: 7, EventType: "comment.created", Trigger: "mentioned", Delivery: connector.DeliveryExposed, Content: "do it"}, true, nil
+}
+
+func (f *fakeDispatch) Ack(_ context.Context, eventID int64, ackID *int64) (connector.Receipt, error) {
+ f.acks = append(f.acks, ackID)
+ if f.err != nil {
+ return connector.Receipt{}, f.err
+ }
+ return connector.Receipt{EventID: eventID, Delivery: connector.DeliveryDelivered, AckID: ackID}, nil
+}
+
+func (f *fakeDispatch) Complete(_ context.Context, eventID int64, c connector.Completion) (connector.Receipt, error) {
+ f.completes = append(f.completes, c)
+ if f.err != nil {
+ return connector.Receipt{}, f.err
+ }
+ return connector.Receipt{EventID: eventID, Delivery: connector.DeliveryCompleted, Outcome: c.Outcome, Links: c.Links, ReplyID: c.ReplyID}, nil
+}
+
+func noUpstream(t *testing.T) *httptest.Server {
+ t.Helper()
+ upstream := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ t.Errorf("the connect domain must never reach Basecamp: %s %s", r.Method, r.URL.Path)
+ }))
+ t.Cleanup(upstream.Close)
+ return upstream
+}
+
+func connectSession(t *testing.T, d Dispatch) *fakeDispatchSession {
+ t.Helper()
+ srv, err := New(newTestAPI(noUpstream(t)), Config{Connect: d})
+ require.NoError(t, err)
+ return &fakeDispatchSession{t: t, session: mcptest.Connect(t, srv.BuildMCPServer(slog.New(slog.DiscardHandler)))}
+}
+
+type fakeDispatchSession struct {
+ t *testing.T
+ session *mcp.ClientSession
+}
+
+func (s *fakeDispatchSession) call(action string, params map[string]any) (string, bool) {
+ s.t.Helper()
+ args := map[string]any{"action": action}
+ if params != nil {
+ args["params"] = params
+ }
+ return mcptest.CallText(s.t, s.session, connectToolName, args)
+}
+
+// Done when: a server started without the token does not expose the domain.
+func TestTheConnectDomainExistsOnlyWhenConfigured(t *testing.T) {
+ srv, err := New(newTestAPI(noUpstream(t)), Config{})
+ require.NoError(t, err)
+ tools := mcptest.ListTools(t, mcptest.Connect(t, srv.BuildMCPServer(slog.New(slog.DiscardHandler))))
+ assert.NotContains(t, tools, connectToolName)
+
+ srv, err = New(newTestAPI(noUpstream(t)), Config{Connect: &fakeDispatch{}})
+ require.NoError(t, err)
+ tools = mcptest.ListTools(t, mcptest.Connect(t, srv.BuildMCPServer(slog.New(slog.DiscardHandler))))
+ require.Contains(t, tools, connectToolName)
+ var actions []string
+ for _, line := range strings.Split(tools[connectToolName].Description, "\n") {
+ if name, _, ok := strings.Cut(strings.TrimPrefix(line, "- "), ":"); ok && strings.HasPrefix(line, "- ") {
+ actions = append(actions, name)
+ }
+ }
+ assert.Equal(t, []string{ackDispatchAction, completeDispatch, getDispatchAction}, actions,
+ "exactly these three: a worker never reads other tasks")
+}
+
+func TestTheConnectDomainIsNeverReadOnly(t *testing.T) {
+ _, err := New(newTestAPI(noUpstream(t)), Config{Connect: &fakeDispatch{}, ReadOnly: true})
+ require.Error(t, err)
+}
+
+func TestGetDispatchOverMCP(t *testing.T) {
+ d := &fakeDispatch{}
+ s := connectSession(t, d)
+
+ text, isError := s.call(getDispatchAction, nil)
+ require.False(t, isError, text)
+ var body struct {
+ Instruction connector.Instruction `json:"instruction"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(text), &body))
+ assert.Equal(t, int64(7), body.Instruction.EventID)
+
+ _, isError = s.call(getDispatchAction, map[string]any{"event_id": "12"})
+ require.False(t, isError)
+ assert.Equal(t, []int64{0, 12}, d.getIDs, "no event_id asks for the earliest")
+
+ text, isError = s.call(getDispatchAction, map[string]any{"task_id": 1})
+ assert.True(t, isError)
+ assert.Contains(t, text, "unknown parameter")
+
+ d.none = true
+ text, isError = s.call(getDispatchAction, nil)
+ require.False(t, isError, text)
+ assert.Contains(t, text, `"instruction": null`)
+}
+
+func TestAckAndCompleteOverMCP(t *testing.T) {
+ d := &fakeDispatch{}
+ s := connectSession(t, d)
+
+ text, isError := s.call(ackDispatchAction, map[string]any{"event_id": 7, "ack_id": 99})
+ require.False(t, isError, text)
+ assert.Contains(t, text, `"delivery": "delivered"`)
+ require.Len(t, d.acks, 1)
+ require.NotNil(t, d.acks[0])
+ assert.Equal(t, int64(99), *d.acks[0])
+
+ _, isError = s.call(ackDispatchAction, map[string]any{})
+ assert.True(t, isError, "event_id is required")
+
+ text, isError = s.call(completeDispatch, map[string]any{
+ "event_id": 7, "outcome": "succeeded", "links": []any{"https://example.com/pr"}, "reply_id": 100,
+ })
+ require.False(t, isError, text)
+ require.Len(t, d.completes, 1)
+ assert.Equal(t, connector.OutcomeSucceeded, d.completes[0].Outcome)
+ assert.Equal(t, []string{"https://example.com/pr"}, d.completes[0].Links)
+ require.NotNil(t, d.completes[0].ReplyID)
+
+ _, isError = s.call(completeDispatch, map[string]any{"event_id": 7})
+ assert.True(t, isError, "outcome is required")
+ _, isError = s.call(completeDispatch, map[string]any{"event_id": 7, "outcome": "succeeded", "links": []any{1}})
+ assert.True(t, isError, "links are strings")
+}
+
+func TestConnectRefusalsAreNamed(t *testing.T) {
+ for kind, err := range map[string]error{
+ "task_token_refused": connector.ErrTaskTokenRefused,
+ "not_on_task": connector.ErrNotOnTask,
+ "not_exposed": connector.ErrNotExposed,
+ "report_conflict": connector.ErrReportConflict,
+ "not_dispatchable": connector.ErrNotDispatchable,
+ } {
+ t.Run(kind, func(t *testing.T) {
+ s := connectSession(t, &fakeDispatch{err: fmt.Errorf("connector: event 7: %w", err)})
+ for _, call := range []struct {
+ action string
+ params map[string]any
+ }{
+ {getDispatchAction, nil},
+ {ackDispatchAction, map[string]any{"event_id": 7}},
+ {completeDispatch, map[string]any{"event_id": 7, "outcome": "failed"}},
+ } {
+ text, isError := s.call(call.action, call.params)
+ assert.True(t, isError)
+ assert.Contains(t, text, `"error": "`+kind+`"`)
+ }
+ })
+ }
+
+ s := connectSession(t, &fakeDispatch{err: errors.New("connector: disk I/O error")})
+ text, isError := s.call(getDispatchAction, nil)
+ assert.True(t, isError)
+ assert.Contains(t, text, "disk I/O error")
+}
diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go
index 3383ec754..43944ec6f 100644
--- a/internal/mcpserver/dispatch.go
+++ b/internal/mcpserver/dispatch.go
@@ -56,7 +56,8 @@ var _ API = (*basecamp.AccountClient)(nil)
// request body property. The describe action serves the schema for all
// three. Failures are in-band isError results per MCP convention.
type dispatcher struct {
- api API
+ api API
+ connect Dispatch
}
func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.Operation, params map[string]any) (*mcp.CallToolResult, error) {
@@ -69,6 +70,11 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O
return gateway.ErrorResult("internal error: action %q not in domain %q", op.Action, dom.Name()), nil
}
+ // The connector's own domain is served from its ledger, never Basecamp.
+ if dom.Name() == connectDomainKey {
+ return d.handleConnect(ctx, full, params)
+ }
+
// Composite actions are SDK compositions, not model operations: they
// have no method and no path to assemble, so they are served before
// buildRequest ever looks for one.
diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go
index 1b7b4ca90..cc607ac9b 100644
--- a/internal/mcpserver/server.go
+++ b/internal/mcpserver/server.go
@@ -24,6 +24,10 @@ type Config struct {
// Domains narrows the served domains by key ("projects", "todos", ...).
// Empty means all. Unknown keys are a startup error — fail closed.
Domains []string
+ // Connect, when set, serves the basecamp_connect domain from it: the one
+ // task a connector-started worker was given. Unset, the domain does not
+ // exist on this server.
+ Connect Dispatch
}
// Server wraps the toolkit gateway serving Basecamp's derived catalog,
@@ -44,10 +48,24 @@ func New(api API, cfg Config) (*Server, error) {
return nil, fmt.Errorf("derive catalog: %w", err)
}
+ if cfg.Connect != nil {
+ if cfg.ReadOnly {
+ // Every connect action records something; a read-only server
+ // would serve the domain with nothing in it.
+ return nil, fmt.Errorf("the %s domain cannot be served read-only", connectToolName)
+ }
+ for _, d := range cat.Domains {
+ if d.Key == connectDomainKey {
+ return nil, fmt.Errorf("the model already serves a %q domain; the connector's would shadow it", connectDomainKey)
+ }
+ }
+ cat.Domains = append(cat.Domains, connectDomain())
+ }
+
gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{
ReadOnly: cfg.ReadOnly,
Domains: cfg.Domains,
- Handler: dispatcher{api: api}.handle,
+ Handler: dispatcher{api: api, connect: cfg.Connect}.handle,
})
if err != nil {
return nil, err
From 3cee0bf65ced3300fc7a000f229828398a7ab43a Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 08:32:33 +0200
Subject: [PATCH 02/33] One live task per event; the three actions agree on
what is dispatchable
A retried or concurrent launch could put one event on two live tasks and hand
it to two workers. The database now refuses a second live task for an event
(task_events_one_live_task), task creation dispatches its records in the same
transaction, and superseding a task retires its events. createTask runs in a
caller's transaction for the dispatcher.
get_dispatch serves only a dispatched or completed record with its content,
and its earliest skips any other, so one withdrawn or blocked event no longer
hides the task. A worker's report is recorded whatever the record did since;
only a dispatched record is completed by it.
The worker's server opens the ledger without creating or migrating it, keeps
the connect domain under --domains, refuses --read-only before touching the
token, refuses a token for no live task at startup, compares accounts as
numbers, and keeps ledger failures out of the transcript. Mention stripping
walks the markup the way the SDK's reader does.
---
internal/commands/mcp.go | 39 ++-
internal/commands/mcp_connect_test.go | 56 +++-
internal/connector/ledger.go | 65 +++-
internal/connector/ledger_dispatch.go | 361 +++++++++++++++------
internal/connector/ledger_dispatch_test.go | 213 ++++++++++--
internal/mcpserver/connect.go | 14 +-
internal/mcpserver/connect_test.go | 21 +-
internal/mcpserver/server.go | 6 +
8 files changed, 623 insertions(+), 152 deletions(-)
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index d927e7a45..8105853ff 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -1,6 +1,7 @@
package commands
import (
+ "context"
"errors"
"fmt"
"log/slog"
@@ -78,7 +79,12 @@ func NewMCPCmd() *cobra.Command {
cfg := mcpserver.Config{ReadOnly: readOnly, Domains: domains}
if connectState != "" {
- dispatch, closeLedger, err := openConnectDispatch(connectState, app.Config.AccountID)
+ if readOnly {
+ // Every connect action records something; refused before
+ // the token or the ledger is touched.
+ return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
+ }
+ dispatch, closeLedger, err := openConnectDispatch(cmd.Context(), connectState, app.Config.AccountID)
if err != nil {
return err
}
@@ -121,12 +127,14 @@ func NewMCPCmd() *cobra.Command {
// agent's id comes from, and a ledger for another account is refused rather
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
-func openConnectDispatch(stateDir, accountID string) (*connector.TaskDispatch, func(), error) {
+func openConnectDispatch(ctx context.Context, stateDir, accountID string) (*connector.TaskDispatch, func(), error) {
token := os.Getenv(connectTaskTokenEnv)
if strings.TrimSpace(token) == "" {
return nil, nil, output.ErrUsage("--connect-state needs the task token in $" + connectTaskTokenEnv + "; the connector sets it when it starts a worker")
}
- // Nothing this process starts needs it.
+ // Nothing this process starts needs it. This clears it from what the
+ // process hands on, not from its own /proc environ, which only this user
+ // can read.
_ = os.Unsetenv(connectTaskTokenEnv)
name := filepath.Base(filepath.Clean(stateDir))
@@ -135,25 +143,34 @@ func openConnectDispatch(stateDir, accountID string) (*connector.TaskDispatch, f
if !ok || err != nil || agentID <= 0 || account == "" {
return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state %q is not a connector state directory (named -)", stateDir))
}
- if account != accountID {
+ if !sameAccount(account, accountID) {
return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state %q belongs to account %s, not %s", stateDir, account, accountID))
}
- path := filepath.Join(stateDir, connector.LedgerFile)
- if _, err := os.Lstat(path); err != nil {
+ // The connector owns the ledger: a worker's server opens it as it is, and
+ // never creates or migrates it.
+ ledger, err := connector.OpenExistingLedger(ctx, filepath.Join(stateDir, connector.LedgerFile))
+ if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, output.ErrUsage(fmt.Sprintf("no connector ledger in %s", stateDir))
}
return nil, nil, err
}
- ledger, err := connector.OpenLedger(path)
- if err != nil {
- return nil, nil, err
- }
- dispatch, err := ledger.Dispatch(token, agentID)
+ dispatch, err := ledger.Dispatch(ctx, token, agentID)
if err != nil {
_ = ledger.Close()
+ if errors.Is(err, connector.ErrTaskTokenRefused) {
+ return nil, nil, output.ErrUsage("$" + connectTaskTokenEnv + " names no current task in " + stateDir)
+ }
return nil, nil, err
}
return dispatch, func() { _ = ledger.Close() }, nil
}
+
+// sameAccount compares two account ids as numbers, so "0999" and "999" are one
+// account.
+func sameAccount(a, b string) bool {
+ x, errA := strconv.ParseUint(a, 10, 64)
+ y, errB := strconv.ParseUint(b, 10, 64)
+ return errA == nil && errB == nil && x == y
+}
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 27b8d7df6..815a9aa9a 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -104,6 +104,51 @@ func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) {
assert.Equal(t, connector.StateDispatched, record.State, "exposure was written to the connector's ledger")
}
+func TestMCPCommandMatchesTheAccountAsANumber(t *testing.T) {
+ dir, grant, _ := connectStateWithTask(t)
+ t.Setenv(connectTaskTokenEnv, grant.Token)
+ t.Setenv("BASECAMP_TOKEN", "test-token")
+ app := setupMCPTestApp(t, "0999", unusedUpstream(t).URL)
+ clientTransport := stubMCPTransport(t)
+ done := make(chan error, 1)
+ go func() { done <- executeMCPCommand(t, app, "--connect-state", dir+"/") }()
+ // Raced against the command: one that refuses the directory exits without
+ // serving, and the client's connect would wait on it forever.
+ type connected struct {
+ session *mcp.ClientSession
+ err error
+ }
+ connecting := make(chan connected, 1)
+ go func() {
+ client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil)
+ session, err := client.Connect(context.Background(), clientTransport, nil)
+ connecting <- connected{session, err}
+ }()
+ var session *mcp.ClientSession
+ select {
+ case cmdErr := <-done:
+ require.NoError(t, cmdErr, "basecamp mcp refused to serve")
+ t.Fatal("basecamp mcp exited before serving")
+ case c := <-connecting:
+ require.NoError(t, c.err)
+ session = c.session
+ }
+ assert.Contains(t, toolNames(t, session), "basecamp_connect")
+ require.NoError(t, session.Close())
+ require.NoError(t, <-done)
+}
+
+func TestMCPCommandRefusesReadOnlyBeforeTouchingTheToken(t *testing.T) {
+ dir, grant, _ := connectStateWithTask(t)
+ t.Setenv("BASECAMP_TOKEN", "test-token")
+ t.Setenv(connectTaskTokenEnv, grant.Token)
+ app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
+
+ err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only")
+ require.Error(t, err)
+ assert.Equal(t, grant.Token, os.Getenv(connectTaskTokenEnv))
+}
+
func TestMCPCommandWithoutConnectStateHasNoConnectDomain(t *testing.T) {
_, grant, _ := connectStateWithTask(t)
t.Setenv(connectTaskTokenEnv, grant.Token)
@@ -124,11 +169,12 @@ func TestMCPCommandRefusesABadConnectState(t *testing.T) {
for name, tc := range map[string]struct {
dir, token, want string
}{
- "no token": {dir, "", connectTaskTokenEnv},
- "another account": {otherAccount, grant.Token, "belongs to account 1000"},
- "not a state dir": {notAStateDir, grant.Token, "not a connector state directory"},
- "no ledger": {empty, grant.Token, "no connector ledger"},
- "read-only refused": {dir, grant.Token, "read-only"},
+ "no token": {dir, "", connectTaskTokenEnv},
+ "another account": {otherAccount, grant.Token, "belongs to account 1000"},
+ "not a state dir": {notAStateDir, grant.Token, "not a connector state directory"},
+ "no ledger": {empty, grant.Token, "no connector ledger"},
+ "read-only refused": {dir, grant.Token, "read-only"},
+ "a token for no task": {dir, "not-a-task-token", "names no current task"},
} {
t.Run(name, func(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "test-token")
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 00cd7113f..1e82cb338 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -77,6 +77,26 @@ type Ledger struct {
// OpenLedger opens (creating if absent) the ledger at path and brings its
// schema up to date.
func OpenLedger(path string) (*Ledger, error) {
+ return openLedger(context.Background(), path, true)
+}
+
+// ErrLedgerSchema is a ledger whose schema is not the one this binary writes.
+var ErrLedgerSchema = errors.New("the connector ledger's schema is not the version this basecamp writes")
+
+// OpenExistingLedger opens a ledger the connector already created, for a
+// process that reads and reports into it rather than owns it — a worker's
+// MCP server. It never creates the file and never migrates: a different
+// basecamp binary started as a worker must not change the schema under the
+// connector that holds it, so a ledger at any other schema version is
+// refused.
+func OpenExistingLedger(ctx context.Context, path string) (*Ledger, error) {
+ if _, err := os.Lstat(path); err != nil {
+ return nil, fmt.Errorf("connector: open ledger: %w", err)
+ }
+ return openLedger(ctx, path, false)
+}
+
+func openLedger(ctx context.Context, path string, migrate bool) (*Ledger, error) {
if path == "" {
return nil, errors.New("connector: ledger path is required")
}
@@ -108,9 +128,26 @@ func OpenLedger(path string) (*Ledger, error) {
db.SetMaxOpenConns(1)
l := &Ledger{db: db, now: time.Now}
- if err := retryBusy(func() error { return l.migrate(context.Background()) }); err != nil {
- _ = db.Close()
- return nil, err
+ if migrate {
+ if err := retryBusy(func() error { return l.migrate(ctx) }); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ } else {
+ var version int
+ err := retryBusy(func() error {
+ var err error
+ version, err = l.schemaVersion(ctx)
+ return err
+ })
+ if err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("connector: read ledger schema: %w", err)
+ }
+ if version != len(migrations) {
+ _ = db.Close()
+ return nil, fmt.Errorf("connector: ledger at schema %d, this basecamp writes %d: %w", version, len(migrations), ErrLedgerSchema)
+ }
}
// The WAL and shared-memory sidecars exist now and were created under the
// process umask. The private directory already keeps other users out;
@@ -339,6 +376,12 @@ CREATE INDEX events_conversation ON events (conversation_key, state);
// back, held by the trigger as the events lifecycle is. guard is the
// thirty-second acknowledgement guard: '' where none applies, armed until
// get_dispatch cancels it or the connector fires it.
+ //
+ // An event is on at most one live task. retired_at is set on every row of
+ // a task when it is superseded, and the unique index over the rows not
+ // retired is what refuses a second live task for the same event — in the
+ // database, so a dispatcher retrying a launch cannot hand one event to two
+ // workers whatever order its writes land in.
`
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -361,9 +404,12 @@ CREATE TABLE task_events (
outcome TEXT NOT NULL DEFAULT '',
links TEXT NOT NULL DEFAULT '[]',
reply_id INTEGER,
+ retired_at TEXT,
PRIMARY KEY (task_id, event_id)
);
+CREATE UNIQUE INDEX task_events_one_live_task ON task_events (event_id) WHERE retired_at IS NULL;
+
CREATE TRIGGER task_events_delivery_moves_forward
BEFORE UPDATE OF delivery ON task_events
WHEN (CASE NEW.delivery WHEN 'admitted' THEN 0 WHEN 'exposed' THEN 1 WHEN 'delivered' THEN 2 ELSE 3 END)
@@ -418,6 +464,19 @@ func (l *Ledger) migrate(ctx context.Context) error {
// SchemaVersion reports the highest applied migration.
func (l *Ledger) SchemaVersion(ctx context.Context) (int, error) {
+ return l.schemaVersion(ctx)
+}
+
+// schemaVersion reads the version, and reads a ledger with no migration table
+// as version 0 rather than failing.
+func (l *Ledger) schemaVersion(ctx context.Context) (int, error) {
+ var tables int
+ if err := l.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&tables); err != nil {
+ return 0, err
+ }
+ if tables == 0 {
+ return 0, nil
+ }
var version int
err := l.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&version)
return version, err
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 2889fb8f7..95d6dbc1b 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -10,12 +10,15 @@ import (
"encoding/json"
"errors"
"fmt"
+ "html"
"net/url"
- "regexp"
"strconv"
"strings"
"time"
+ "modernc.org/sqlite"
+ sqlite3 "modernc.org/sqlite/lib"
+
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)
@@ -57,9 +60,14 @@ var (
// Reported outcomes stand.
ErrReportConflict = errors.New("the event already has a different report")
// ErrNotDispatchable is an event whose record left the path to a worker
- // (a person discarded it, or its content was dropped) after it joined
- // the task.
+ // after it joined the task: blocked or withdrawn, or its content dropped.
ErrNotDispatchable = errors.New("the event can no longer be dispatched")
+ // ErrInvalidReport is a report the worker can correct: an outcome that
+ // is not one of the two, a link that is not a URL, too many links.
+ ErrInvalidReport = errors.New("the report is not valid")
+ // ErrEventOnLiveTask is an event a live task already carries. Handing it
+ // to a second task would give two workers one instruction.
+ ErrEventOnLiveTask = errors.New("the event is already on a live task")
)
// TaskGrant is a new task and the token that binds a worker to it. The token
@@ -69,10 +77,40 @@ type TaskGrant struct {
Token string
}
-// CreateTask puts admitted or queued records on a new task, each at delivery
-// admitted, with the acknowledgement guard armed for the records whose
-// verdict asks for an acknowledgement.
+// CreateTask puts records on a new task at delivery admitted, with the
+// acknowledgement guard armed for the records whose verdict asks for one, and
+// moves each record to dispatched in the same transaction: a record is
+// dispatched exactly while a live task carries it.
+//
+// An event is on at most one live task. A second task for an event whose task
+// was not superseded is refused with ErrEventOnLiveTask, and nothing is
+// written. The refusal is the database's own (task_events_one_live_task), so a
+// retried or concurrent launch cannot get past it. A redispatch supersedes the
+// old task first.
func (l *Ledger) CreateTask(ctx context.Context, eventIDs []int64) (TaskGrant, error) {
+ var grant TaskGrant
+ err := retryBusy(func() error {
+ tx, err := l.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("connector: begin task: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if grant, err = l.createTask(ctx, tx, eventIDs); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("connector: commit task: %w", err)
+ }
+ return nil
+ })
+ return grant, err
+}
+
+// createTask writes a task inside the caller's transaction, so the dispatcher
+// can write the task, its attempt and the originating event's exposure as one
+// commit. Every guarantee CreateTask documents holds within tx; nothing is
+// committed here, and a refusal leaves tx for the caller to roll back.
+func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (TaskGrant, error) {
if len(eventIDs) == 0 {
return TaskGrant{}, errors.New("connector: a task needs at least one event")
}
@@ -81,12 +119,6 @@ func (l *Ledger) CreateTask(ctx context.Context, eventIDs []int64) (TaskGrant, e
return TaskGrant{}, fmt.Errorf("connector: task token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
-
- tx, err := l.db.BeginTx(ctx, nil)
- if err != nil {
- return TaskGrant{}, fmt.Errorf("connector: begin task: %w", err)
- }
- defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at) VALUES (?, ?)`, tokenHash(token), l.timestamp())
if err != nil {
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
@@ -96,41 +128,62 @@ func (l *Ledger) CreateTask(ctx context.Context, eventIDs []int64) (TaskGrant, e
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
}
for _, id := range eventIDs {
- var (
- state string
- acknowledge int
- )
- switch err := tx.QueryRowContext(ctx, `SELECT state, acknowledge FROM events WHERE id = ?`, id).Scan(&state, &acknowledge); {
+ var acknowledge int
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge FROM events WHERE id = ?`, id).Scan(&acknowledge); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
- if RecordState(state) != StateAdmitted && RecordState(state) != StateQueued {
- return TaskGrant{}, fmt.Errorf("connector: task event %d is %s; only an admitted or queued record joins a task", id, state)
- }
guard := ""
if acknowledge != 0 {
guard = "armed"
}
if _, err := tx.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id, guard) VALUES (?, ?, ?)`, taskID, id, guard); err != nil {
+ if isConstraint(err) {
+ return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrEventOnLiveTask)
+ }
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
- }
- if err := tx.Commit(); err != nil {
- return TaskGrant{}, fmt.Errorf("connector: commit task: %w", err)
+ // Admitted or queued work joins a task; a dispatched record whose
+ // task was superseded joins its replacement.
+ moved, err := l.move(ctx, tx, transition{id: id, state: StateDispatched, from: []RecordState{StateAdmitted, StateQueued, StateDispatched}})
+ if err != nil {
+ return TaskGrant{}, err
+ }
+ if !moved {
+ var state string
+ _ = tx.QueryRowContext(ctx, `SELECT state FROM events WHERE id = ?`, id).Scan(&state)
+ return TaskGrant{}, fmt.Errorf("connector: task event %d is %s; only admitted, queued or redispatched work joins a task", id, state)
+ }
}
return TaskGrant{ID: taskID, Token: token}, nil
}
-// SupersedeTask retires a task's token. Every later dispatch call made with
-// it is refused.
+// SupersedeTask retires a task: its token is refused from then on, and its
+// events are free to join a new task.
func (l *Ledger) SupersedeTask(ctx context.Context, taskID int64) error {
- _, err := l.db.ExecContext(ctx, `UPDATE tasks SET superseded_at = COALESCE(superseded_at, ?) WHERE id = ?`, l.timestamp(), taskID)
- if err != nil {
- return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
- }
- return nil
+ return retryBusy(func() error {
+ tx, err := l.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("connector: begin supersede: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ now := l.timestamp()
+ if _, err := tx.ExecContext(ctx, `UPDATE tasks SET superseded_at = COALESCE(superseded_at, ?) WHERE id = ?`, now, taskID); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE task_events SET retired_at = COALESCE(retired_at, ?) WHERE task_id = ?`, now, taskID); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ return tx.Commit()
+ })
+}
+
+// isConstraint reports a SQLite constraint violation.
+func isConstraint(err error) bool {
+ var sqliteErr *sqlite.Error
+ return errors.As(err, &sqliteErr) && sqliteErr.Code()&0xff == sqlite3.SQLITE_CONSTRAINT
}
func tokenHash(token string) string {
@@ -147,18 +200,27 @@ type TaskDispatch struct {
agentID int64
}
-// Dispatch binds the ledger to a worker's task token. The token is checked on
-// every call, in the call's own transaction, so a redispatch that supersedes
-// it takes effect at once. agentID is the agent's Person id, whose own
-// mentions are stripped from the instructions handed out.
-func (l *Ledger) Dispatch(token string, agentID int64) (*TaskDispatch, error) {
+// Dispatch binds the ledger to a worker's task token, refusing one that names
+// no live task now. The token is checked again on every call, in the call's
+// own transaction, so a redispatch that supersedes it later takes effect at
+// once. agentID is the agent's Person id, whose own mentions are stripped from
+// the instructions handed out.
+func (l *Ledger) Dispatch(ctx context.Context, token string, agentID int64) (*TaskDispatch, error) {
if strings.TrimSpace(token) == "" {
return nil, errors.New("connector: a dispatch needs the task token")
}
if agentID <= 0 {
return nil, errors.New("connector: a dispatch needs the agent's Person id")
}
- return &TaskDispatch{ledger: l, hash: tokenHash(token), agentID: agentID}, nil
+ d := &TaskDispatch{ledger: l, hash: tokenHash(token), agentID: agentID}
+ var live int
+ if err := l.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE token_sha256 = ? AND superseded_at IS NULL`, d.hash).Scan(&live); err != nil {
+ return nil, fmt.Errorf("connector: resolve task token: %w", err)
+ }
+ if live == 0 {
+ return nil, fmt.Errorf("connector: %w", ErrTaskTokenRefused)
+ }
+ return d, nil
}
// Instruction is what get_dispatch hands a worker. It is an allowlist: every
@@ -228,9 +290,13 @@ const (
// Get returns the instruction for eventID, or for the earliest event on the
// task not yet acknowledged when eventID is zero; ok is false when there is
-// none. Handing out an event that was never exposed writes exposed — and moves
-// its record to dispatched — before the instruction is returned, and cancels
-// an armed guard. A repeat returns the same instruction and writes nothing.
+// none. Handing out an event that was never exposed writes exposed before the
+// instruction is returned, and cancels an armed guard. A repeat returns the
+// same instruction and writes nothing.
+//
+// Only an event whose record is still on the way to a worker is handed out:
+// dispatched, or completed, with its content. The earliest skips any other,
+// so one event withdrawn or blocked never hides the rest of the task.
func (d *TaskDispatch) Get(ctx context.Context, eventID int64) (Instruction, bool, error) {
var (
out Instruction
@@ -267,9 +333,10 @@ func (d *TaskDispatch) get(ctx context.Context, eventID int64) (Instruction, boo
if eventID == 0 {
err := tx.QueryRowContext(ctx, `
-SELECT event_id FROM task_events
-WHERE task_id = ? AND delivery IN ('admitted', 'exposed')
-ORDER BY event_id LIMIT 1`, taskID).Scan(&eventID)
+SELECT te.event_id FROM task_events te JOIN events e ON e.id = te.event_id
+WHERE te.task_id = ? AND te.delivery IN ('admitted', 'exposed')
+ AND e.state = 'dispatched' AND e.content_dropped = 0 AND e.snapshot IS NOT NULL
+ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return Instruction{}, false, nil
}
@@ -285,29 +352,16 @@ ORDER BY event_id LIMIT 1`, taskID).Scan(&eventID)
if err != nil {
return Instruction{}, false, err
}
- if record.ContentDropped || len(record.Decision.Snapshot) == 0 {
+ servable := record.State == StateDispatched || record.State == StateCompleted
+ if !servable || record.ContentDropped || len(record.Decision.Snapshot) == 0 {
return Instruction{}, false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
}
now := l.timestamp()
wrote := false
if te.delivery == DeliveryAdmitted {
- // Exposure is written before anything about the event leaves this
- // call, and the record moves to dispatched with it: a worker that
- // was handed an instruction may act on it whether or not it reports.
- switch record.State {
- case StateAdmitted, StateQueued:
- moved, err := l.move(ctx, tx, transition{id: eventID, state: StateDispatched, from: []RecordState{StateAdmitted, StateQueued}})
- if err != nil {
- return Instruction{}, false, err
- }
- if !moved {
- return Instruction{}, false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
- }
- case StateDispatched:
- default:
- return Instruction{}, false, fmt.Errorf("connector: event %d is %s: %w", eventID, record.State, ErrNotDispatchable)
- }
+ // Written before anything about the event leaves this call: a worker
+ // handed an instruction may act on it whether or not it reports.
if _, err := tx.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = ? WHERE task_id = ? AND event_id = ? AND delivery = 'admitted'`, now, taskID, eventID); err != nil {
return Instruction{}, false, fmt.Errorf("connector: expose event %d: %w", eventID, err)
}
@@ -385,12 +439,14 @@ WHERE task_id = ? AND event_id = ?`, d.ledger.timestamp(), nullableID(ackID), ta
}
// Complete records the worker's outcome and acknowledges the event if it was
-// not already; the record moves to completed. A repeat of the same report
+// not already. The report is recorded whatever has happened to the record
+// since the worker was handed it, because it is what the worker did; the
+// record moves to completed when it is dispatched. A repeat of the same report
// answers the same receipt; a different one is refused, because a reported
// outcome stands.
func (d *TaskDispatch) Complete(ctx context.Context, eventID int64, c Completion) (Receipt, error) {
if c.Outcome != OutcomeSucceeded && c.Outcome != OutcomeFailed {
- return Receipt{}, fmt.Errorf("connector: outcome must be %q or %q", OutcomeSucceeded, OutcomeFailed)
+ return Receipt{}, fmt.Errorf("connector: outcome must be %q or %q: %w", OutcomeSucceeded, OutcomeFailed, ErrInvalidReport)
}
links, err := normalizeLinks(c.Links)
if err != nil {
@@ -410,13 +466,9 @@ func (d *TaskDispatch) Complete(ctx context.Context, eventID int64, c Completion
}
return false, fmt.Errorf("connector: event %d completed as %s: %w", eventID, te.outcome, ErrReportConflict)
}
- moved, err := d.ledger.move(ctx, tx, transition{id: eventID, state: StateCompleted, from: []RecordState{StateDispatched}})
- if err != nil {
+ if _, err := d.ledger.move(ctx, tx, transition{id: eventID, state: StateCompleted, from: []RecordState{StateDispatched}}); err != nil {
return false, err
}
- if !moved {
- return false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
- }
now := d.ledger.timestamp()
_, err = tx.ExecContext(ctx, `
UPDATE task_events
@@ -526,16 +578,16 @@ func loadRecord(ctx context.Context, tx *sql.Tx, id int64) (Record, error) {
func normalizeLinks(links []string) ([]string, error) {
if len(links) > maxCompletionLinks {
- return nil, fmt.Errorf("connector: at most %d links", maxCompletionLinks)
+ return nil, fmt.Errorf("connector: at most %d links: %w", maxCompletionLinks, ErrInvalidReport)
}
out := make([]string, 0, len(links))
for _, link := range links {
if len(link) > maxLinkLength {
- return nil, fmt.Errorf("connector: a link is at most %d characters", maxLinkLength)
+ return nil, fmt.Errorf("connector: a link is at most %d characters: %w", maxLinkLength, ErrInvalidReport)
}
u, err := url.Parse(link)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
- return nil, fmt.Errorf("connector: link %q is not an http(s) URL", link)
+ return nil, fmt.Errorf("connector: link %q is not an http(s) URL: %w", link, ErrInvalidReport)
}
out = append(out, link)
}
@@ -556,61 +608,156 @@ func sameID(stored sql.NullInt64, given *int64) bool {
return stored.Valid && stored.Int64 == *given
}
-// attachmentOpen is a start tag, and attachmentSGID its sgid
-// attribute. Basecamp serves rich text sanitized, with attributes
-// double-quoted and no attachment nested inside another.
-var (
- attachmentOpen = regexp.MustCompile(`(?i)]*>`)
- attachmentClose = regexp.MustCompile(`(?i)`)
- attachmentSGID = regexp.MustCompile(`(?i)\ssgid\s*=\s*"([^"]*)"`)
-)
-
// StripMentionsOf removes every mention of personID from rich text, and
// leaves every other attachment — other people's mentions, files — as it was.
// A worker handed its own mention reads an instruction addressed to itself,
// which says nothing the dispatch does not already say.
//
-// A mention element runs from its start tag to the first closing tag, unless
-// another attachment starts first or none closes, in which case the start
-// tag — self-closing, or never closed — stands alone.
+// The markup is walked tag by tag, the way the SDK's mention reader walks it,
+// so the two agree on what a mention is: a comment is not markup, a ">" in a
+// quoted attribute does not end its tag, either quote style works, and the
+// sgid is entity-decoded before it is read. A mention element runs from its
+// start tag to the first closing tag, unless another attachment starts first
+// or none closes, in which case the start tag stands alone.
func StripMentionsOf(richText string, personID int64) string {
var out strings.Builder
pos := 0
- for {
- loc := attachmentOpen.FindStringIndex(richText[pos:])
- if loc == nil {
- out.WriteString(richText[pos:])
- return out.String()
+ for pos < len(richText) {
+ t, ok := nextTag(richText, pos)
+ if !ok {
+ break
+ }
+ out.WriteString(richText[pos:t.start])
+ pos = t.end
+ if strings.EqualFold(t.name, "bc-attachment") {
+ if id, isPerson := basecamp.PersonIDFromSGID(html.UnescapeString(t.sgid)); isPerson && id == personID {
+ pos = mentionEnd(richText, t.end)
+ continue
+ }
+ }
+ out.WriteString(richText[t.start:t.end])
+ }
+ out.WriteString(richText[pos:])
+ return out.String()
+}
+
+// mentionEnd is where the mention whose start tag ends at from ends: after its
+// closing tag, or at from when another attachment starts first or none closes.
+func mentionEnd(text string, from int) int {
+ for at := from; ; {
+ t, ok := nextTag(text, at)
+ if !ok || strings.EqualFold(t.name, "bc-attachment") {
+ return from
+ }
+ if strings.EqualFold(t.name, "/bc-attachment") {
+ return t.end
+ }
+ at = t.end
+ }
+}
+
+// tag is one start or end tag: its bounds, its name ("/name" for an end tag)
+// and its sgid attribute, raw.
+type tag struct {
+ start, end int
+ name, sgid string
+}
+
+// nextTag finds the next complete tag at or after pos, skipping comments. ok
+// is false when none remains; a tag or comment left unterminated ends the
+// markup, as it does for a browser.
+func nextTag(text string, pos int) (tag, bool) {
+ for pos < len(text) {
+ i := strings.IndexByte(text[pos:], '<')
+ if i < 0 {
+ return tag{}, false
+ }
+ start := pos + i
+ rest := text[start+1:]
+ if strings.HasPrefix(rest, "!--") {
+ stop := strings.Index(rest[3:], "-->")
+ if stop < 0 {
+ return tag{}, false
+ }
+ pos = start + 1 + 3 + stop + 3
+ continue
+ }
+ n := 0
+ if strings.HasPrefix(rest, "/") {
+ n = 1
}
- start, end := pos+loc[0], pos+loc[1]
- out.WriteString(richText[pos:start])
- pos = end
-
- tag := richText[start:end]
- match := attachmentSGID.FindStringSubmatch(tag)
- id, ok := int64(0), false
- if match != nil {
- id, ok = basecamp.PersonIDFromSGID(unescapeAttribute(match[1]))
+ nameStart := n
+ for n < len(rest) && isTagNameByte(rest[n]) {
+ n++
}
- if !ok || id != personID {
- out.WriteString(tag)
+ if n == nameStart {
+ pos = start + 1
continue
}
- rest := richText[end:]
- closing := attachmentClose.FindStringIndex(rest)
- next := attachmentOpen.FindStringIndex(rest)
- if closing != nil && (next == nil || closing[0] < next[0]) {
- pos = end + closing[1]
+ t := tag{start: start, name: rest[:n]}
+ at := start + 1 + n
+ for at < len(text) {
+ c := text[at]
+ switch {
+ case c == '>':
+ t.end = at + 1
+ return t, true
+ case isTagNameByte(c):
+ attrStart := at
+ for at < len(text) && isTagNameByte(text[at]) {
+ at++
+ }
+ attr := text[attrStart:at]
+ for at < len(text) && isSpaceByte(text[at]) {
+ at++
+ }
+ if at >= len(text) || text[at] != '=' {
+ continue
+ }
+ at++
+ for at < len(text) && isSpaceByte(text[at]) {
+ at++
+ }
+ value, next := attributeValue(text, at)
+ if next < 0 {
+ return tag{}, false
+ }
+ if t.sgid == "" && strings.EqualFold(attr, "sgid") {
+ t.sgid = value
+ }
+ at = next
+ default:
+ at++
+ }
}
+ return tag{}, false
}
+ return tag{}, false
}
-func unescapeAttribute(value string) string {
- if !strings.Contains(value, "&") {
- return value
+// attributeValue reads a quoted or bare attribute value at pos and returns it
+// with the position after it; next is -1 for an unterminated quote.
+func attributeValue(text string, pos int) (value string, next int) {
+ if pos < len(text) && (text[pos] == '"' || text[pos] == '\'') {
+ end := strings.IndexByte(text[pos+1:], text[pos])
+ if end < 0 {
+ return "", -1
+ }
+ return text[pos+1 : pos+1+end], pos + end + 2
+ }
+ end := pos
+ for end < len(text) && !isSpaceByte(text[end]) && text[end] != '>' {
+ end++
}
- replacer := strings.NewReplacer(""", `"`, "'", "'", "<", "<", ">", ">", "+", "+", "+", "+", "=", "=", "=", "=", "&", "&")
- return replacer.Replace(value)
+ return text[pos:end], end
+}
+
+func isTagNameByte(c byte) bool {
+ return c == '-' || c == '_' || c == ':' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
+}
+
+func isSpaceByte(c byte) bool {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
}
// StateDirName is the connector's state directory for one account and agent:
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index fe5d8e171..0792c09b9 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -3,6 +3,7 @@ package connector
import (
"context"
"encoding/json"
+ "fmt"
"sort"
"strings"
"testing"
@@ -39,7 +40,7 @@ func newDispatchFixture(t *testing.T) dispatchFixture {
require.Equal(t, StateQueued, getRecord(t, ledger, 2).State)
grant, err := ledger.CreateTask(ctx, []int64{1, 2})
require.NoError(t, err)
- d, err := ledger.Dispatch(grant.Token, adapterAgentID)
+ d, err := ledger.Dispatch(ctx, grant.Token, adapterAgentID)
require.NoError(t, err)
return dispatchFixture{ledger: ledger, grant: grant, d: d}
}
@@ -71,6 +72,8 @@ func TestGetDispatchExposesOnceAndRepeatsWriteNothing(t *testing.T) {
ctx := context.Background()
t0 := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
f.ledger.now = func() time.Time { return t0 }
+ record := getRecord(t, f.ledger, 2)
+ require.Equal(t, StateDispatched, record.State)
first, ok, err := f.d.Get(ctx, 2)
require.NoError(t, err)
@@ -79,8 +82,7 @@ func TestGetDispatchExposesOnceAndRepeatsWriteNothing(t *testing.T) {
row := f.row(t, 2)
assert.Equal(t, "exposed", row.Delivery)
require.NotNil(t, row.ExposedAt)
- record := getRecord(t, f.ledger, 2)
- assert.Equal(t, StateDispatched, record.State, "a follow-up handed to a worker is dispatched")
+ assert.Equal(t, "admitted", f.row(t, 1).Delivery, "the other event is not exposed")
f.ledger.now = func() time.Time { return t0.Add(time.Minute) }
again, ok, err := f.d.Get(ctx, 2)
@@ -88,8 +90,7 @@ func TestGetDispatchExposesOnceAndRepeatsWriteNothing(t *testing.T) {
require.True(t, ok)
assert.Equal(t, first, again, "a repeat returns the same instruction")
assert.Equal(t, row, f.row(t, 2), "and marks nothing further")
- assert.Equal(t, record.Revision, getRecord(t, f.ledger, 2).Revision)
- assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 1).State, "the other event is untouched")
+ assert.Equal(t, record.Revision, getRecord(t, f.ledger, 2).Revision, "exposure is the task's, not the record's")
}
func TestGetDispatchWithoutAnIDIsTheEarliestNotAcknowledged(t *testing.T) {
@@ -274,7 +275,6 @@ func TestAReportNeedsTheEventHandedOut(t *testing.T) {
_, err = f.d.Complete(ctx, 2, Completion{Outcome: OutcomeSucceeded})
assert.ErrorIs(t, err, ErrNotExposed)
assert.Equal(t, "admitted", f.row(t, 2).Delivery)
- assert.Equal(t, StateQueued, getRecord(t, f.ledger, 2).State)
}
// Done when: a superseded token is refused.
@@ -295,9 +295,9 @@ func TestASupersededTokenIsRefused(t *testing.T) {
assert.Equal(t, "admitted", f.row(t, 2).Delivery, "a refused get exposes nothing")
assert.Equal(t, "exposed", f.row(t, 1).Delivery)
- stranger, err := f.ledger.Dispatch("not-a-token", adapterAgentID)
- require.NoError(t, err)
- _, _, err = stranger.Get(ctx, 1)
+ _, err = f.ledger.Dispatch(ctx, "not-a-token", adapterAgentID)
+ assert.ErrorIs(t, err, ErrTaskTokenRefused, "refused when bound, not only on use")
+ _, err = f.ledger.Dispatch(ctx, f.grant.Token, adapterAgentID)
assert.ErrorIs(t, err, ErrTaskTokenRefused)
}
@@ -316,17 +316,23 @@ func TestAWorkerSeesOnlyItsOwnTask(t *testing.T) {
assert.ErrorIs(t, err, ErrNotOnTask)
_, _, err = f.d.Get(ctx, 404)
assert.ErrorIs(t, err, ErrNotOnTask, "an unknown event reads the same as another task's")
- assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 3).State)
}
func TestAnEventThatLeftThePathIsNotHandedOut(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
- require.NoError(t, f.ledger.SetState(ctx, 2, StateDiscarded, "by_operator"))
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateBlocked, "no_route"))
_, _, err := f.d.Get(ctx, 2)
assert.ErrorIs(t, err, ErrNotDispatchable)
assert.Equal(t, "admitted", f.row(t, 2).Delivery)
+
+ // Withdrawn back to admitted, content and all: not a worker's any more.
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateAdmitted, ""))
+ require.NotEmpty(t, getRecord(t, f.ledger, 1).Decision.Snapshot)
+ _, _, err = f.d.Get(ctx, 1)
+ assert.ErrorIs(t, err, ErrNotDispatchable)
+ assert.Equal(t, "admitted", f.row(t, 1).Delivery)
}
// Retention took the instruction: a completed event asked for again answers
@@ -348,20 +354,173 @@ func TestAnEventWhoseContentWasDroppedIsNotHandedOut(t *testing.T) {
assert.ErrorIs(t, err, ErrNotDispatchable)
}
-// The dispatcher writes the originating event's record as dispatched when it
-// launches the worker. get_dispatch exposes it all the same.
-func TestGetDispatchExposesAnEventAlreadyDispatchedAtLaunch(t *testing.T) {
+// A record is dispatched exactly while a live task carries it: joining a task
+// moves it there, in the task's own transaction.
+func TestCreateTaskDispatchesItsRecords(t *testing.T) {
+ f := newDispatchFixture(t)
+ assert.Equal(t, StateDispatched, getRecord(t, f.ledger, 1).State)
+ assert.Equal(t, StateDispatched, getRecord(t, f.ledger, 2).State, "the queued follow-up too")
+}
+
+// An event is on at most one live task. A retried launch is refused and
+// writes nothing; after a redispatch supersedes the task, it joins a new one.
+func TestAnEventIsOnOneLiveTask(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
- require.NoError(t, f.ledger.SetState(ctx, 1, StateDispatched, ""))
- revision := getRecord(t, f.ledger, 1).Revision
+ countTasks := func() int {
+ var n int
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks`).Scan(&n))
+ return n
+ }
- got, ok, err := f.d.Get(ctx, 1)
+ _, err := f.ledger.CreateTask(ctx, []int64{1})
+ assert.ErrorIs(t, err, ErrEventOnLiveTask)
+ _, err = f.ledger.CreateTask(ctx, []int64{2, 1})
+ assert.ErrorIs(t, err, ErrEventOnLiveTask)
+ assert.Equal(t, 1, countTasks(), "a refused task leaves no task behind")
+
+ // The database refuses it too, whoever writes.
+ _, err = f.ledger.db.ExecContext(ctx, `INSERT INTO tasks (id, token_sha256, created_at) VALUES (99, 'x', 'now')`)
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id) VALUES (99, 1)`)
+ require.Error(t, err)
+
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ grant, err := f.ledger.CreateTask(ctx, []int64{1, 2})
+ require.NoError(t, err)
+ d, err := f.ledger.Dispatch(ctx, grant.Token, adapterAgentID)
+ require.NoError(t, err)
+ got, ok, err := d.Get(ctx, 0)
require.NoError(t, err)
require.True(t, ok)
- assert.Equal(t, DeliveryExposed, got.Delivery)
- assert.Equal(t, "exposed", f.row(t, 1).Delivery)
- assert.Equal(t, revision, getRecord(t, f.ledger, 1).Revision, "the record was already where exposure puts it")
+ assert.Equal(t, int64(1), got.EventID)
+ _, _, err = f.d.Get(ctx, 1)
+ assert.ErrorIs(t, err, ErrTaskTokenRefused)
+}
+
+// The dispatcher writes a task with its attempt and exposure in one commit;
+// a task created in a transaction that rolls back leaves nothing, and does not
+// hold the event.
+func TestCreateTaskInsideACallersTransaction(t *testing.T) {
+ ledger := newTestLedger(t)
+ ctx := context.Background()
+ seenRecord(t, ledger, 1)
+ _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
+ require.NoError(t, err)
+
+ tx, err := ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ _, err = ledger.createTask(ctx, tx, []int64{1})
+ require.NoError(t, err)
+ require.NoError(t, tx.Rollback())
+ assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State)
+
+ tx, err = ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ _, err = ledger.createTask(ctx, tx, []int64{1})
+ require.NoError(t, err)
+ _, err = ledger.createTask(ctx, tx, []int64{1})
+ require.ErrorIs(t, err, ErrEventOnLiveTask, "one live task per event within a transaction too")
+ require.NoError(t, tx.Rollback())
+
+ tx, err = ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ grant, err := ledger.createTask(ctx, tx, []int64{1})
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit())
+ _, err = ledger.Dispatch(ctx, grant.Token, adapterAgentID)
+ require.NoError(t, err)
+ assert.Equal(t, StateDispatched, getRecord(t, ledger, 1).State)
+}
+
+func TestConcurrentLaunchesOfOneEventMakeOneTask(t *testing.T) {
+ ledger := newTestLedger(t)
+ ctx := context.Background()
+ seenRecord(t, ledger, 1)
+ _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
+ require.NoError(t, err)
+
+ const racers = 8
+ errs := make([]error, racers)
+ done := make(chan struct{})
+ for i := range racers {
+ go func() {
+ defer func() { done <- struct{}{} }()
+ _, errs[i] = ledger.CreateTask(ctx, []int64{1})
+ }()
+ }
+ for range racers {
+ <-done
+ }
+ created := 0
+ for _, err := range errs {
+ if err == nil {
+ created++
+ continue
+ }
+ assert.ErrorIs(t, err, ErrEventOnLiveTask)
+ }
+ assert.Equal(t, 1, created)
+ var live int
+ require.NoError(t, ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_events WHERE event_id = 1 AND retired_at IS NULL`).Scan(&live))
+ assert.Equal(t, 1, live)
+}
+
+// One event that left the path never hides the rest of the task.
+func TestTheEarliestSkipsAnEventThatLeftThePath(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
+
+ got, ok, err := f.d.Get(ctx, 0)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(2), got.EventID)
+}
+
+// A worker's report is what it did: it is recorded even when the record has
+// moved since, and only a dispatched record is completed by it.
+func TestAReportIsRecordedWhateverHappenedToTheRecord(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 2)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateAdmitted, ""))
+
+ _, err = f.d.Ack(ctx, 2, nil)
+ require.NoError(t, err)
+ receipt, err := f.d.Complete(ctx, 2, Completion{Outcome: OutcomeFailed})
+ require.NoError(t, err)
+ assert.Equal(t, DeliveryCompleted, receipt.Delivery)
+ assert.Equal(t, OutcomeFailed, receipt.Outcome)
+ assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State)
+}
+
+func TestOpenExistingLedgerNeverCreatesOrMigrates(t *testing.T) {
+ dir := t.TempDir() + "/state"
+ _, err := OpenExistingLedger(context.Background(), dir+"/missing.db")
+ require.Error(t, err)
+
+ path := dir + "/connector.db"
+ all := migrations
+ migrations = all[:4]
+ old, err := OpenLedger(path)
+ migrations = all
+ require.NoError(t, err)
+ require.NoError(t, old.Close())
+
+ _, err = OpenExistingLedger(context.Background(), path)
+ require.ErrorIs(t, err, ErrLedgerSchema)
+ again, err := OpenLedger(path)
+ require.NoError(t, err)
+ version, err := again.SchemaVersion(context.Background())
+ require.NoError(t, err)
+ require.NoError(t, again.Close())
+ assert.Equal(t, len(migrations), version, "migrated only by the owner's open")
+
+ current, err := OpenExistingLedger(context.Background(), path)
+ require.NoError(t, err)
+ require.NoError(t, current.Close())
}
func TestDeliveryNeverGoesBack(t *testing.T) {
@@ -424,7 +583,7 @@ func TestCompleteRefusesMalformedReports(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
_, err := f.d.Complete(ctx, 1, c)
- require.Error(t, err)
+ require.ErrorIs(t, err, ErrInvalidReport)
assert.Equal(t, "exposed", f.rowContext(ctx, t, 1).Delivery)
})
}
@@ -448,9 +607,21 @@ func TestStripMentionsOf(t *testing.T) {
"unclosed, before another": {unclosed + " x " + other, " x " + other},
"every occurrence": {agent + " and " + agent, " and "},
"no attachments": {"
plain
", "
plain
"},
+ "single-quoted sgid": {"a" + strings.ReplaceAll(agent, `"`, "'") + "b", "ab"},
+ "a > inside another attribute": {"a" + strings.Replace(agent, "" + other, "" + other},
+ "uppercase": {"a" + strings.ToUpper(agent[:14]) + agent[14:] + "b", "ab"},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, StripMentionsOf(tc.in, adapterAgentID))
})
}
}
+
+// entityEncodedSGID writes the mention's sgid with its first character as a
+// hex entity, the way a serializer may.
+func entityEncodedSGID(mention string) string {
+ i := strings.Index(mention, `sgid="`) + len(`sgid="`)
+ return mention[:i] + fmt.Sprintf("%x;", mention[i]) + mention[i+1:]
+}
diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go
index dbba79596..ad4b69064 100644
--- a/internal/mcpserver/connect.go
+++ b/internal/mcpserver/connect.go
@@ -3,7 +3,7 @@ package mcpserver
import (
"context"
"errors"
- "fmt"
+ "strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -220,9 +220,15 @@ func connectFailure(err error) *mcp.CallToolResult {
{connector.ErrNotExposed, "not_exposed"},
{connector.ErrReportConflict, "report_conflict"},
{connector.ErrNotDispatchable, "not_dispatchable"},
+ {connector.ErrInvalidReport, "invalid_report"},
} {
if errors.Is(err, known.err) {
- result, encodeErr := gateway.JSONResult(map[string]any{"error": known.kind, "message": known.err.Error()})
+ message := known.err.Error()
+ if known.err == connector.ErrInvalidReport {
+ // What is wrong with the report is the worker's to fix.
+ message = strings.TrimPrefix(err.Error(), "connector: ")
+ }
+ result, encodeErr := gateway.JSONResult(map[string]any{"error": known.kind, "message": message})
if encodeErr != nil || result == nil {
return gateway.ErrorResult("%s", known.kind)
}
@@ -233,5 +239,7 @@ func connectFailure(err error) *mcp.CallToolResult {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return gateway.ErrorResult("the call was canceled")
}
- return gateway.ErrorResult("%s", fmt.Sprint(err))
+ // Anything else is the ledger failing, not the worker: its detail stays
+ // out of the model's transcript.
+ return gateway.ErrorResult("the connector ledger could not answer; try again, and report it if it persists")
}
diff --git a/internal/mcpserver/connect_test.go b/internal/mcpserver/connect_test.go
index 38834d5ae..2c70c2e90 100644
--- a/internal/mcpserver/connect_test.go
+++ b/internal/mcpserver/connect_test.go
@@ -104,6 +104,16 @@ func TestTheConnectDomainExistsOnlyWhenConfigured(t *testing.T) {
"exactly these three: a worker never reads other tasks")
}
+// A server narrowed with --domains still serves the task's own domain.
+func TestTheConnectDomainSurvivesNarrowing(t *testing.T) {
+ srv, err := New(newTestAPI(noUpstream(t)), Config{Connect: &fakeDispatch{}, Domains: []string{"todos"}})
+ require.NoError(t, err)
+ tools := mcptest.ListTools(t, mcptest.Connect(t, srv.BuildMCPServer(slog.New(slog.DiscardHandler))))
+ assert.Contains(t, tools, connectToolName)
+ assert.Contains(t, tools, "basecamp_todos")
+ assert.Len(t, tools, 2)
+}
+
func TestTheConnectDomainIsNeverReadOnly(t *testing.T) {
_, err := New(newTestAPI(noUpstream(t)), Config{Connect: &fakeDispatch{}, ReadOnly: true})
require.Error(t, err)
@@ -171,6 +181,7 @@ func TestConnectRefusalsAreNamed(t *testing.T) {
"not_exposed": connector.ErrNotExposed,
"report_conflict": connector.ErrReportConflict,
"not_dispatchable": connector.ErrNotDispatchable,
+ "invalid_report": connector.ErrInvalidReport,
} {
t.Run(kind, func(t *testing.T) {
s := connectSession(t, &fakeDispatch{err: fmt.Errorf("connector: event 7: %w", err)})
@@ -189,8 +200,14 @@ func TestConnectRefusalsAreNamed(t *testing.T) {
})
}
- s := connectSession(t, &fakeDispatch{err: errors.New("connector: disk I/O error")})
+ s := connectSession(t, &fakeDispatch{err: errors.New("connector: set state of 7: disk I/O error")})
text, isError := s.call(getDispatchAction, nil)
assert.True(t, isError)
- assert.Contains(t, text, "disk I/O error")
+ assert.NotContains(t, text, "disk I/O error", "the ledger's own failure stays out of the transcript")
+ assert.Contains(t, text, "connector ledger could not answer")
+
+ s = connectSession(t, &fakeDispatch{err: fmt.Errorf("connector: link %q is not an http(s) URL: %w", "ftp://x", connector.ErrInvalidReport)})
+ text, isError = s.call(completeDispatch, map[string]any{"event_id": 7, "outcome": "failed"})
+ assert.True(t, isError)
+ assert.Contains(t, text, "ftp://x", "what the worker got wrong is said")
}
diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go
index cc607ac9b..e03aa01fa 100644
--- a/internal/mcpserver/server.go
+++ b/internal/mcpserver/server.go
@@ -3,6 +3,7 @@ package mcpserver
import (
"fmt"
"log/slog"
+ "slices"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -60,6 +61,11 @@ func New(api API, cfg Config) (*Server, error) {
}
}
cat.Domains = append(cat.Domains, connectDomain())
+ // A server started for a task serves the task's domain whatever else
+ // it is narrowed to: without it the worker cannot pull its dispatch.
+ if len(cfg.Domains) > 0 && !slices.Contains(cfg.Domains, connectDomainKey) {
+ cfg.Domains = append(slices.Clone(cfg.Domains), connectDomainKey)
+ }
}
gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{
From 8883096a723bf49b65e45bf02bf40c876307f083 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 08:43:48 +0200
Subject: [PATCH 03/33] Make a task only of instructions a worker can pull
createTask refuses a record whose snapshot was dropped, which a supersede and
a bookkeeping move back to admitted could otherwise dispatch as a task whose
worker is told nothing is waiting. An id named twice for one task gets its
own error rather than reading as another task's event.
---
internal/connector/ledger_dispatch.go | 17 +++++++++++++++--
internal/connector/ledger_dispatch_test.go | 19 +++++++++++++++++++
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 95d6dbc1b..cc4254c0c 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -114,6 +114,13 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
if len(eventIDs) == 0 {
return TaskGrant{}, errors.New("connector: a task needs at least one event")
}
+ seen := make(map[int64]bool, len(eventIDs))
+ for _, id := range eventIDs {
+ if seen[id] {
+ return TaskGrant{}, fmt.Errorf("connector: event %d is named twice for one task", id)
+ }
+ seen[id] = true
+ }
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return TaskGrant{}, fmt.Errorf("connector: task token: %w", err)
@@ -128,13 +135,19 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
}
for _, id := range eventIDs {
- var acknowledge int
- switch err := tx.QueryRowContext(ctx, `SELECT acknowledge FROM events WHERE id = ?`, id).Scan(&acknowledge); {
+ var acknowledge, servable int
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL FROM events WHERE id = ?`, id).Scan(&acknowledge, &servable); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
+ if servable == 0 {
+ // A task a worker could pull nothing from would read as a task
+ // with nothing left to do. A record without its instruction
+ // needs a new verdict first.
+ return TaskGrant{}, fmt.Errorf("connector: task event %d has no instruction: %w", id, ErrNotDispatchable)
+ }
guard := ""
if acknowledge != 0 {
guard = "armed"
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 0792c09b9..bf6fad76f 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -433,6 +433,25 @@ func TestCreateTaskInsideACallersTransaction(t *testing.T) {
assert.Equal(t, StateDispatched, getRecord(t, ledger, 1).State)
}
+// A task is only ever made of instructions a worker can pull. A record that
+// lost its snapshot on the way through blocked is refused, not dispatched as
+// an empty task.
+func TestCreateTaskRefusesARecordWithoutItsInstruction(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateAdmitted, ""))
+
+ _, err := f.ledger.CreateTask(ctx, []int64{1})
+ require.ErrorIs(t, err, ErrNotDispatchable)
+ assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 1).State)
+
+ _, err = f.ledger.CreateTask(ctx, []int64{2, 2})
+ require.Error(t, err)
+ assert.NotErrorIs(t, err, ErrEventOnLiveTask, "a duplicate id is not another task's event")
+}
+
func TestConcurrentLaunchesOfOneEventMakeOneTask(t *testing.T) {
ledger := newTestLedger(t)
ctx := context.Background()
From 2e6e9e7d0aebf7feac0afc7df945ff8ea6d04ac2 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 08:46:21 +0200
Subject: [PATCH 04/33] Open an existing ledger without any path that can
create it
OpenExistingLedger checked the file existed and then ran the owner's privacy
check, which creates a missing file, and SQLite's default open, which does
too. A ledger removed in between left a worker holding a new empty one. The
existing-ledger path now uses setup.CheckPrivateFile, which inspects without
creating, and opens SQLite with mode=rw.
---
internal/connector/ledger.go | 45 +++++++++++++------
internal/connector/ledger_dispatch_test.go | 32 +++++++++++++
internal/connector/setup/private_state.go | 28 ++++++++++++
internal/connector/setup/private_unix_test.go | 25 +++++++++++
4 files changed, 116 insertions(+), 14 deletions(-)
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 1e82cb338..cfea8ab13 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -89,14 +89,32 @@ var ErrLedgerSchema = errors.New("the connector ledger's schema is not the versi
// basecamp binary started as a worker must not change the schema under the
// connector that holds it, so a ledger at any other schema version is
// refused.
+//
+// Neither the privacy check nor SQLite may create the file on this path: the
+// check only inspects, and the database is opened with mode=rw, so a ledger
+// removed at any moment is an error rather than a new empty one.
func OpenExistingLedger(ctx context.Context, path string) (*Ledger, error) {
- if _, err := os.Lstat(path); err != nil {
- return nil, fmt.Errorf("connector: open ledger: %w", err)
- }
return openLedger(ctx, path, false)
}
-func openLedger(ctx context.Context, path string, migrate bool) (*Ledger, error) {
+// ledgerDSN is the SQLite URI for the ledger at path. owner opens it the way
+// the connector does, creating it when absent; otherwise mode=rw makes SQLite
+// refuse a file that is not there.
+//
+// _txlock=immediate takes the write lock when a transaction opens rather than
+// on its first write. Without it two connectors racing on one file can both
+// start, both read, and one is refused at COMMIT with the work already done.
+func ledgerDSN(path string, owner bool) string {
+ dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate"
+ if !owner {
+ dsn += "&mode=rw"
+ }
+ return dsn
+}
+
+// openLedger opens the ledger; owner is the connector itself, which creates
+// and migrates it. Any other opener does neither.
+func openLedger(ctx context.Context, path string, owner bool) (*Ledger, error) {
if path == "" {
return nil, errors.New("connector: ledger path is required")
}
@@ -110,16 +128,11 @@ func openLedger(ctx context.Context, path string, migrate bool) (*Ledger, error)
// query, fragment or an escape, and open some other file.
return nil, fmt.Errorf("connector: ledger path %q contains a character the SQLite URI cannot carry (?, # or %%)", path)
}
- if err := securePath(path); err != nil {
+ if err := securePath(path, owner); err != nil {
return nil, err
}
- // _txlock=immediate takes the write lock when a transaction opens rather
- // than on its first write. Without it two connectors racing on one file
- // can both start, both read, and one is refused at COMMIT with the work
- // already done.
- dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate"
- db, err := sql.Open("sqlite", dsn)
+ db, err := sql.Open("sqlite", ledgerDSN(path, owner))
if err != nil {
return nil, fmt.Errorf("connector: open ledger: %w", err)
}
@@ -128,7 +141,7 @@ func openLedger(ctx context.Context, path string, migrate bool) (*Ledger, error)
db.SetMaxOpenConns(1)
l := &Ledger{db: db, now: time.Now}
- if migrate {
+ if owner {
if err := retryBusy(func() error { return l.migrate(ctx) }); err != nil {
_ = db.Close()
return nil, err
@@ -212,8 +225,12 @@ func isBusy(err error) bool {
// a ledger whose privacy cannot be established is refused there rather than
// opened — the same way setup refuses to write a trust file it cannot vouch
// for.
-func securePath(path string) error {
- if err := setup.EnsurePrivateFile(path); err != nil {
+func securePath(path string, create bool) error {
+ check := setup.CheckPrivateFile
+ if create {
+ check = setup.EnsurePrivateFile
+ }
+ if err := check(path); err != nil {
return fmt.Errorf("connector: secure the ledger: %w", err)
}
// One rule of the ledger's own, beyond what a trust file needs: its
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index bf6fad76f..085a06696 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -2,8 +2,11 @@ package connector
import (
"context"
+ "database/sql"
"encoding/json"
"fmt"
+ "os"
+ "path/filepath"
"sort"
"strings"
"testing"
@@ -515,6 +518,35 @@ func TestAReportIsRecordedWhateverHappenedToTheRecord(t *testing.T) {
assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State)
}
+// A worker's open never leaves a ledger behind where there was none: not
+// through the privacy check, and not through SQLite, whichever the file
+// disappears before.
+func TestOpenExistingLedgerCreatesNothing(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ require.NoError(t, os.Mkdir(dir, 0o700))
+ path := filepath.Join(dir, LedgerFile)
+
+ _, err := OpenExistingLedger(context.Background(), path)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ assert.Empty(t, entries, "the privacy check created nothing")
+
+ // The file gone after the check: SQLite itself must refuse.
+ db, err := sql.Open("sqlite", ledgerDSN(path, false))
+ require.NoError(t, err)
+ defer db.Close()
+ require.Error(t, db.PingContext(context.Background()))
+ entries, err = os.ReadDir(dir)
+ require.NoError(t, err)
+ assert.Empty(t, entries, "SQLite created nothing")
+
+ owner, err := sql.Open("sqlite", ledgerDSN(path, true))
+ require.NoError(t, err)
+ defer owner.Close()
+ require.NoError(t, owner.PingContext(context.Background()), "the connector's own open still creates")
+}
+
func TestOpenExistingLedgerNeverCreatesOrMigrates(t *testing.T) {
dir := t.TempDir() + "/state"
_, err := OpenExistingLedger(context.Background(), dir+"/missing.db")
diff --git a/internal/connector/setup/private_state.go b/internal/connector/setup/private_state.go
index 81b55d319..109232cc8 100644
--- a/internal/connector/setup/private_state.go
+++ b/internal/connector/setup/private_state.go
@@ -147,6 +147,34 @@ func EnsurePrivateFile(path string) error {
return checkPrivateReadableFile(f, path)
}
+// CheckPrivateFile holds an existing file to EnsurePrivateFile's rules without
+// creating anything: every directory on the way must be this user's alone, the
+// file must not be a symlink, and — inspected through the open descriptor — it
+// must be this user's own file that nobody else can read. A missing file, or
+// a missing directory, is an error satisfying errors.Is(err, os.ErrNotExist).
+func CheckPrivateFile(path string) error {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(abs)
+ if err := checkAncestors(filepath.Dir(dir)); err != nil {
+ return err
+ }
+ if _, err := os.Lstat(dir); err != nil {
+ return fmt.Errorf("inspect %s: %w", dir, err)
+ }
+ if err := checkPrivateDir(dir); err != nil {
+ return err
+ }
+ f, err := openNoFollow(abs)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return checkPrivateReadableFile(f, abs)
+}
+
func checkPrivateReadableFile(f *os.File, path string) error {
if err := checkPrivateFile(f, path); err != nil {
return err
diff --git a/internal/connector/setup/private_unix_test.go b/internal/connector/setup/private_unix_test.go
index 5b8fdb560..c6a0f4bfe 100644
--- a/internal/connector/setup/private_unix_test.go
+++ b/internal/connector/setup/private_unix_test.go
@@ -187,3 +187,28 @@ func TestLockRefusesASecondSetup(t *testing.T) {
require.NoError(t, err)
unlockAgain()
}
+
+func TestCheckPrivateFileCreatesNothingAndHoldsTheRules(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ require.NoError(t, os.Mkdir(dir, 0o700))
+ path := filepath.Join(dir, "ledger.db")
+
+ err := CheckPrivateFile(path)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ _, statErr := os.Lstat(path)
+ require.ErrorIs(t, statErr, os.ErrNotExist, "nothing was created")
+
+ require.NoError(t, os.WriteFile(path, nil, 0o600))
+ require.NoError(t, CheckPrivateFile(path))
+
+ require.NoError(t, os.Chmod(path, 0o644))
+ require.ErrorIs(t, CheckPrivateFile(path), ErrNotPrivate)
+ require.NoError(t, os.Chmod(path, 0o600))
+
+ link := filepath.Join(dir, "link.db")
+ require.NoError(t, os.Symlink(path, link))
+ require.Error(t, CheckPrivateFile(link))
+
+ require.NoError(t, os.Chmod(dir, 0o775))
+ require.ErrorIs(t, CheckPrivateFile(path), ErrNotPrivate, "a directory others can write")
+}
From 4f5f6d70aa9730cf7f2ed36b31f72844c16ad6b0 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 09:07:49 +0200
Subject: [PATCH 05/33] Supersede returns unexposed work; finished work is
never handed out anew
Superseding a task left its never-exposed events dispatched on no live task.
It now returns them to admitted, as the spec's settlement rule says, and
leaves exposed ones dispatched for their settlement or a redispatch; it also
runs inside a caller's transaction for a redispatch. get_dispatch no longer
serves a record completed before this worker was exposed to it.
---
internal/connector/ledger_dispatch.go | 55 ++++++++++++++++++----
internal/connector/ledger_dispatch_test.go | 48 +++++++++++++++++++
2 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index cc4254c0c..ee8e80a4c 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -174,7 +174,10 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
}
// SupersedeTask retires a task: its token is refused from then on, and its
-// events are free to join a new task.
+// events are free to join a new task. An event the task never exposed returns
+// to admitted, to be dispatched again once its conversation is free; an event
+// a worker was handed stays dispatched, because that worker may have acted on
+// it, and waits for its settlement or a person's redispatch.
func (l *Ledger) SupersedeTask(ctx context.Context, taskID int64) error {
return retryBusy(func() error {
tx, err := l.db.BeginTx(ctx, nil)
@@ -182,17 +185,50 @@ func (l *Ledger) SupersedeTask(ctx context.Context, taskID int64) error {
return fmt.Errorf("connector: begin supersede: %w", err)
}
defer func() { _ = tx.Rollback() }()
- now := l.timestamp()
- if _, err := tx.ExecContext(ctx, `UPDATE tasks SET superseded_at = COALESCE(superseded_at, ?) WHERE id = ?`, now, taskID); err != nil {
- return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
- }
- if _, err := tx.ExecContext(ctx, `UPDATE task_events SET retired_at = COALESCE(retired_at, ?) WHERE task_id = ?`, now, taskID); err != nil {
- return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ if err := l.supersedeTask(ctx, tx, taskID); err != nil {
+ return err
}
return tx.Commit()
})
}
+// supersedeTask is SupersedeTask inside the caller's transaction, so a
+// redispatch can retire the old task and create the new one in one commit.
+func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) error {
+ rows, err := tx.QueryContext(ctx, `SELECT event_id FROM task_events WHERE task_id = ? AND retired_at IS NULL AND delivery = 'admitted'`, taskID)
+ if err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ var unexposed []int64
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ unexposed = append(unexposed, id)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+
+ now := l.timestamp()
+ if _, err := tx.ExecContext(ctx, `UPDATE tasks SET superseded_at = COALESCE(superseded_at, ?) WHERE id = ?`, now, taskID); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE task_events SET retired_at = COALESCE(retired_at, ?) WHERE task_id = ?`, now, taskID); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ for _, id := range unexposed {
+ // Only a record still dispatched moves: one a person or a later
+ // verdict already moved stays where it was put.
+ if _, err := l.move(ctx, tx, transition{id: id, state: StateAdmitted, from: []RecordState{StateDispatched}}); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
// isConstraint reports a SQLite constraint violation.
func isConstraint(err error) bool {
var sqliteErr *sqlite.Error
@@ -365,7 +401,10 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if err != nil {
return Instruction{}, false, err
}
- servable := record.State == StateDispatched || record.State == StateCompleted
+ // A completed record is served again only to the worker it was already
+ // exposed to; finished work is never handed out for the first time.
+ servable := record.State == StateDispatched ||
+ (record.State == StateCompleted && te.delivery != DeliveryAdmitted)
if !servable || record.ContentDropped || len(record.Decision.Snapshot) == 0 {
return Instruction{}, false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
}
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 085a06696..65d5e1ea5 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -455,6 +455,54 @@ func TestCreateTaskRefusesARecordWithoutItsInstruction(t *testing.T) {
assert.NotErrorIs(t, err, ErrEventOnLiveTask, "a duplicate id is not another task's event")
}
+// Superseding a task returns what it never exposed to admitted and leaves
+// what a worker was handed dispatched, so no record is left dispatched on no
+// task without a worker having seen it.
+func TestSupersedingReturnsUnexposedWork(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+
+ assert.Equal(t, StateDispatched, getRecord(t, f.ledger, 1).State, "a worker saw it")
+ assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State, "never exposed, it is work again")
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID), "a repeat is harmless")
+ assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State)
+
+ grant, err := f.ledger.CreateTask(ctx, []int64{2})
+ require.NoError(t, err)
+ d, err := f.ledger.Dispatch(ctx, grant.Token, adapterAgentID)
+ require.NoError(t, err)
+ got, ok, err := d.Get(ctx, 0)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(2), got.EventID)
+}
+
+// Finished work is never handed out for the first time: a record completed
+// before this worker was exposed to it is refused, and one it completed
+// itself is served again.
+func TestCompletedWorkIsServedOnlyToItsWorker(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateCompleted, ""))
+
+ _, _, err := f.d.Get(ctx, 2)
+ assert.ErrorIs(t, err, ErrNotDispatchable)
+ assert.Equal(t, "admitted", f.row(t, 2).Delivery)
+
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, err = f.d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ require.NoError(t, err)
+ got, ok, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, DeliveryCompleted, got.Delivery)
+}
+
func TestConcurrentLaunchesOfOneEventMakeOneTask(t *testing.T) {
ledger := newTestLedger(t)
ctx := context.Background()
From b1a06c4bc5ad1e3cadab1be77b8e95b4fe1b7f64 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 09:30:57 +0200
Subject: [PATCH 06/33] One task per conversation, one mention parser, one
state-root check
Three classes of finding, fixed where each belongs.
A conversation has one task at a time: createTask refuses an event whose
conversation has a dispatched record outside the task being created, so the
siblings a supersede returns wait behind the event a worker still holds
rather than starting tasks of their own.
The mention stripper is no longer a second parser by accident. It is the
SDK reader's walk, rule for rule, and the two are held together by a
differential test over hostile markup and a fuzz target: no mention of the
agent survives, no one else's is lost, text without one is untouched, and
every span removed is one the reader reads as the agent's mention in place.
An explicit get and the earliest now share one servable rule.
A state directory is accepted in one place, connector.ResolveStateDir: it
must be the canonical directory under the connector's state root and carry
this account's number, so a ledger copied elsewhere and renamed is refused.
The task token is taken out of the environment before authentication, which
can start helper processes.
---
internal/commands/mcp.go | 57 ++-
internal/commands/mcp_connect_test.go | 126 +++----
internal/commands/mcp_test.go | 8 +-
internal/connector/ledger_dispatch.go | 393 +++++++++++++++------
internal/connector/ledger_dispatch_test.go | 103 ++++++
internal/connector/strip_mentions_test.go | 150 ++++++++
6 files changed, 631 insertions(+), 206 deletions(-)
create mode 100644 internal/connector/strip_mentions_test.go
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 8105853ff..f826f4455 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -8,7 +8,6 @@ import (
"os"
"os/signal"
"path/filepath"
- "strconv"
"strings"
"syscall"
@@ -59,6 +58,19 @@ func NewMCPCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
+ // The task token is taken out of the environment before anything
+ // else runs: authentication can start helper processes, and a
+ // child started then would inherit it.
+ var taskToken string
+ if connectState != "" {
+ if readOnly {
+ // Every connect action records something; refused before
+ // the token or the ledger is touched.
+ return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
+ }
+ taskToken = takeConnectTaskToken()
+ }
+
// CheckAuthenticated, not IsAuthenticated: this refuses to
// start the server, and a store it merely could not read —
// another process mid-write, a keyring that would not open —
@@ -79,12 +91,7 @@ func NewMCPCmd() *cobra.Command {
cfg := mcpserver.Config{ReadOnly: readOnly, Domains: domains}
if connectState != "" {
- if readOnly {
- // Every connect action records something; refused before
- // the token or the ledger is touched.
- return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
- }
- dispatch, closeLedger, err := openConnectDispatch(cmd.Context(), connectState, app.Config.AccountID)
+ dispatch, closeLedger, err := openConnectDispatch(cmd.Context(), connectState, app.Config.AccountID, taskToken)
if err != nil {
return err
}
@@ -119,6 +126,16 @@ func NewMCPCmd() *cobra.Command {
return cmd
}
+// takeConnectTaskToken reads the task token and removes it from the
+// environment, so nothing this process starts inherits it. That clears it from
+// what the process hands on, not from its own /proc environ, which only this
+// user can read.
+func takeConnectTaskToken() string {
+ token := os.Getenv(connectTaskTokenEnv)
+ _ = os.Unsetenv(connectTaskTokenEnv)
+ return token
+}
+
// openConnectDispatch opens the connector's ledger in stateDir and binds it to
// the task token in the environment.
//
@@ -127,24 +144,14 @@ func NewMCPCmd() *cobra.Command {
// agent's id comes from, and a ledger for another account is refused rather
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
-func openConnectDispatch(ctx context.Context, stateDir, accountID string) (*connector.TaskDispatch, func(), error) {
- token := os.Getenv(connectTaskTokenEnv)
+func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
if strings.TrimSpace(token) == "" {
return nil, nil, output.ErrUsage("--connect-state needs the task token in $" + connectTaskTokenEnv + "; the connector sets it when it starts a worker")
}
- // Nothing this process starts needs it. This clears it from what the
- // process hands on, not from its own /proc environ, which only this user
- // can read.
- _ = os.Unsetenv(connectTaskTokenEnv)
- name := filepath.Base(filepath.Clean(stateDir))
- account, agent, ok := strings.Cut(name, "-")
- agentID, err := strconv.ParseInt(agent, 10, 64)
- if !ok || err != nil || agentID <= 0 || account == "" {
- return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state %q is not a connector state directory (named -)", stateDir))
- }
- if !sameAccount(account, accountID) {
- return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state %q belongs to account %s, not %s", stateDir, account, accountID))
+ agentID, err := connector.ResolveStateDir(stateDir, accountID)
+ if err != nil {
+ return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state: %s", strings.TrimPrefix(err.Error(), "connector: ")))
}
// The connector owns the ledger: a worker's server opens it as it is, and
@@ -166,11 +173,3 @@ func openConnectDispatch(ctx context.Context, stateDir, accountID string) (*conn
}
return dispatch, func() { _ = ledger.Close() }, nil
}
-
-// sameAccount compares two account ids as numbers, so "0999" and "999" are one
-// account.
-func sameAccount(a, b string) bool {
- x, errA := strconv.ParseUint(a, 10, 64)
- y, errB := strconv.ParseUint(b, 10, 64)
- return errA == nil && errB == nil && x == y
-}
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 815a9aa9a..424a65c11 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -7,7 +7,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
- "strings"
"testing"
"time"
@@ -17,6 +16,7 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp/eventfeed"
+ "github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
)
@@ -24,11 +24,17 @@ import (
const connectTestAgentID int64 = 52007412
// connectStateWithTask builds the connector's state directory for account 999
-// and the agent, with one admitted mention on a task, and returns the
-// directory and the task's grant.
+// and the agent under a private state home, with one admitted mention on a
+// task, and returns the directory and the task's grant. XDG_STATE_HOME is set
+// to that home, so run it after setupMCPTestApp, which sets its own.
func connectStateWithTask(t *testing.T) (string, connector.TaskGrant, *connector.Ledger) {
t.Helper()
- dir := filepath.Join(t.TempDir(), connector.StateDirName("999", connectTestAgentID))
+ home := t.TempDir()
+ t.Setenv("XDG_STATE_HOME", home)
+ root, err := connector.StateRoot()
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(root, 0o700))
+ dir := filepath.Join(root, connector.StateDirName("999", connectTestAgentID))
require.NoError(t, os.Mkdir(dir, 0o700))
ledger, err := connector.OpenLedger(filepath.Join(dir, connector.LedgerFile))
require.NoError(t, err)
@@ -73,13 +79,23 @@ func toolNames(t *testing.T, session *mcp.ClientSession) []string {
return names
}
+// connectMCPApp builds the app, then the connector state under the state home
+// the command will read.
+func connectMCPApp(t *testing.T, accountID, baseURL string) (*appctx.App, string, connector.TaskGrant, *connector.Ledger) {
+ t.Helper()
+ t.Setenv("BASECAMP_TOKEN", "test-token")
+ app := setupMCPTestApp(t, accountID, baseURL)
+ dir, grant, ledger := connectStateWithTask(t)
+ return app, dir, grant, ledger
+}
+
// Done when: the domain is served from the ledger with the task token, and a
// server started without the token does not expose it.
func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) {
- dir, grant, ledger := connectStateWithTask(t)
+ app, dir, grant, ledger := connectMCPApp(t, "999", unusedUpstream(t).URL)
t.Setenv(connectTaskTokenEnv, grant.Token)
- session := runMCPCommand(t, unusedUpstream(t), "--connect-state", dir)
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir)
assert.Contains(t, toolNames(t, session), "basecamp_connect")
assert.Empty(t, os.Getenv(connectTaskTokenEnv), "the token does not outlive startup in the environment")
@@ -98,97 +114,89 @@ func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) {
assert.NotContains(t, text, "secret-route")
assert.NotContains(t, text, grant.Token)
- record, ok, err := ledger.Get(context.Background(), 1)
+ // Read back through the connector's own handle: a repeat writes nothing,
+ // and reports the delivery the server wrote.
+ d, err := ledger.Dispatch(context.Background(), grant.Token, connectTestAgentID)
+ require.NoError(t, err)
+ again, ok, err := d.Get(context.Background(), 1)
require.NoError(t, err)
require.True(t, ok)
- assert.Equal(t, connector.StateDispatched, record.State, "exposure was written to the connector's ledger")
+ assert.Equal(t, connector.DeliveryExposed, again.Delivery, "exposure was written to the connector's ledger")
}
func TestMCPCommandMatchesTheAccountAsANumber(t *testing.T) {
- dir, grant, _ := connectStateWithTask(t)
+ app, dir, grant, _ := connectMCPApp(t, "0999", unusedUpstream(t).URL)
t.Setenv(connectTaskTokenEnv, grant.Token)
- t.Setenv("BASECAMP_TOKEN", "test-token")
- app := setupMCPTestApp(t, "0999", unusedUpstream(t).URL)
- clientTransport := stubMCPTransport(t)
- done := make(chan error, 1)
- go func() { done <- executeMCPCommand(t, app, "--connect-state", dir+"/") }()
- // Raced against the command: one that refuses the directory exits without
- // serving, and the client's connect would wait on it forever.
- type connected struct {
- session *mcp.ClientSession
- err error
- }
- connecting := make(chan connected, 1)
- go func() {
- client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil)
- session, err := client.Connect(context.Background(), clientTransport, nil)
- connecting <- connected{session, err}
- }()
- var session *mcp.ClientSession
- select {
- case cmdErr := <-done:
- require.NoError(t, cmdErr, "basecamp mcp refused to serve")
- t.Fatal("basecamp mcp exited before serving")
- case c := <-connecting:
- require.NoError(t, c.err)
- session = c.session
- }
+
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir+"/")
assert.Contains(t, toolNames(t, session), "basecamp_connect")
- require.NoError(t, session.Close())
- require.NoError(t, <-done)
+}
+
+// Authentication can start helper processes, so the token is out of the
+// environment before it runs — even when it then fails.
+func TestMCPCommandTakesTheTokenBeforeAuthenticating(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ t.Setenv("BASECAMP_TOKEN", "")
+ t.Setenv(connectTaskTokenEnv, grant.Token)
+
+ err := executeMCPCommand(t, app, "--connect-state", dir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Not authenticated")
+ assert.Empty(t, os.Getenv(connectTaskTokenEnv))
}
func TestMCPCommandRefusesReadOnlyBeforeTouchingTheToken(t *testing.T) {
- dir, grant, _ := connectStateWithTask(t)
- t.Setenv("BASECAMP_TOKEN", "test-token")
+ app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
t.Setenv(connectTaskTokenEnv, grant.Token)
- app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only")
require.Error(t, err)
+ assert.Contains(t, err.Error(), "read-only")
assert.Equal(t, grant.Token, os.Getenv(connectTaskTokenEnv))
}
func TestMCPCommandWithoutConnectStateHasNoConnectDomain(t *testing.T) {
- _, grant, _ := connectStateWithTask(t)
+ app, _, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
t.Setenv(connectTaskTokenEnv, grant.Token)
- session := runMCPCommand(t, unusedUpstream(t))
+ session := runMCPCommandWithApp(t, app)
assert.NotContains(t, toolNames(t, session), "basecamp_connect", "a token alone serves nothing")
}
func TestMCPCommandRefusesABadConnectState(t *testing.T) {
- dir, grant, _ := connectStateWithTask(t)
- otherAccount := filepath.Join(t.TempDir(), connector.StateDirName("1000", connectTestAgentID))
- require.NoError(t, os.Mkdir(otherAccount, 0o700))
- notAStateDir := filepath.Join(t.TempDir(), "connect")
- require.NoError(t, os.Mkdir(notAStateDir, 0o700))
- empty := filepath.Join(t.TempDir(), connector.StateDirName("999", connectTestAgentID))
- require.NoError(t, os.Mkdir(empty, 0o700))
+ app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ root, err := connector.StateRoot()
+ require.NoError(t, err)
+ mkdir := func(path string) string {
+ require.NoError(t, os.MkdirAll(path, 0o700))
+ return path
+ }
+ otherAccount := mkdir(filepath.Join(root, connector.StateDirName("1000", connectTestAgentID)))
+ notAStateDir := mkdir(filepath.Join(root, "connect"))
+ empty := mkdir(filepath.Join(root, connector.StateDirName("999", 1)))
+ // The right name in the wrong place: a copy that renamed itself to match.
+ elsewhere := mkdir(filepath.Join(t.TempDir(), connector.StateDirName("999", connectTestAgentID)))
+ ledger, err := os.ReadFile(filepath.Join(dir, connector.LedgerFile))
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(elsewhere, connector.LedgerFile), ledger, 0o600))
for name, tc := range map[string]struct {
dir, token, want string
}{
"no token": {dir, "", connectTaskTokenEnv},
"another account": {otherAccount, grant.Token, "belongs to account 1000"},
- "not a state dir": {notAStateDir, grant.Token, "not a connector state directory"},
+ "not a state dir": {notAStateDir, grant.Token, "not named -"},
+ "outside the root": {elsewhere, grant.Token, "is not inside"},
"no ledger": {empty, grant.Token, "no connector ledger"},
- "read-only refused": {dir, grant.Token, "read-only"},
"a token for no task": {dir, "not-a-task-token", "names no current task"},
} {
t.Run(name, func(t *testing.T) {
- t.Setenv("BASECAMP_TOKEN", "test-token")
t.Setenv(connectTaskTokenEnv, tc.token)
- app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
- args := []string{"--connect-state", tc.dir}
- if strings.HasPrefix(name, "read-only") {
- args = append(args, "--read-only")
- }
- err := executeMCPCommand(t, app, args...)
+ err := executeMCPCommand(t, app, "--connect-state", tc.dir)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
})
}
- _, err := os.Stat(filepath.Join(empty, connector.LedgerFile))
+ _, err = os.Stat(filepath.Join(empty, connector.LedgerFile))
assert.True(t, os.IsNotExist(err), "a worker's server never creates the connector's ledger")
}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index 2caaf42c2..fb23413c1 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -102,7 +102,13 @@ func stubMCPTransport(t *testing.T) mcp.Transport {
func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp.ClientSession {
t.Helper()
t.Setenv("BASECAMP_TOKEN", "test-token")
- app := setupMCPTestApp(t, "999", upstream.URL)
+ return runMCPCommandWithApp(t, setupMCPTestApp(t, "999", upstream.URL), args...)
+}
+
+// runMCPCommandWithApp is runMCPCommand for an app the test has already
+// built, so it can adjust the environment the command will read.
+func runMCPCommandWithApp(t *testing.T, app *appctx.App, args ...string) *mcp.ClientSession {
+ t.Helper()
clientTransport := stubMCPTransport(t)
done := make(chan error, 1)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index ee8e80a4c..ca42d5d8f 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -12,6 +12,9 @@ import (
"fmt"
"html"
"net/url"
+ "os"
+ "path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -65,6 +68,11 @@ var (
// ErrInvalidReport is a report the worker can correct: an outcome that
// is not one of the two, a link that is not a URL, too many links.
ErrInvalidReport = errors.New("the report is not valid")
+ // ErrConversationBusy is an event whose conversation already has a
+ // dispatched record outside the task being created: a running task, or
+ // work a worker was handed that is not settled yet. A conversation has
+ // one task at a time.
+ ErrConversationBusy = errors.New("the event's conversation already has a task")
// ErrEventOnLiveTask is an event a live task already carries. Handing it
// to a second task would give two workers one instruction.
ErrEventOnLiveTask = errors.New("the event is already on a live task")
@@ -126,6 +134,7 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
return TaskGrant{}, fmt.Errorf("connector: task token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
+
res, err := tx.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at) VALUES (?, ?)`, tokenHash(token), l.timestamp())
if err != nil {
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
@@ -135,14 +144,14 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
}
for _, id := range eventIDs {
- var acknowledge, servable int
- switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL FROM events WHERE id = ?`, id).Scan(&acknowledge, &servable); {
+ var acknowledge, hasInstruction int
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
- if servable == 0 {
+ if hasInstruction == 0 {
// A task a worker could pull nothing from would read as a task
// with nothing left to do. A record without its instruction
// needs a new verdict first.
@@ -158,6 +167,36 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
}
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
+ }
+
+ // One task per conversation: every dispatched record on the events'
+ // conversations must be among the events this task takes. Checked after
+ // every event is on the task — so an event already on a live task is told
+ // as that — and before any of them moves, so the records this call
+ // dispatches never count.
+ placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(eventIDs)), ", ")
+ args := make([]any, 0, len(eventIDs)*2)
+ for _, id := range eventIDs {
+ args = append(args, id)
+ }
+ for _, id := range eventIDs {
+ args = append(args, id)
+ }
+ var busy int64
+ //nolint:gosec // G202: placeholders, not values
+ switch err := tx.QueryRowContext(ctx, `
+SELECT other.id FROM events other
+JOIN events mine ON mine.conversation_key = other.conversation_key
+WHERE mine.id IN (`+placeholders+`) AND mine.conversation_key <> ''
+ AND other.state = 'dispatched' AND other.id NOT IN (`+placeholders+`)
+LIMIT 1`, args...).Scan(&busy); {
+ case err == nil:
+ return TaskGrant{}, fmt.Errorf("connector: event %d is dispatched on the same conversation: %w", busy, ErrConversationBusy)
+ case !errors.Is(err, sql.ErrNoRows):
+ return TaskGrant{}, fmt.Errorf("connector: read conversations: %w", err)
+ }
+
+ for _, id := range eventIDs {
// Admitted or queued work joins a task; a dispatched record whose
// task was superseded joins its replacement.
moved, err := l.move(ctx, tx, transition{id: id, state: StateDispatched, from: []RecordState{StateAdmitted, StateQueued, StateDispatched}})
@@ -383,8 +422,7 @@ func (d *TaskDispatch) get(ctx context.Context, eventID int64) (Instruction, boo
if eventID == 0 {
err := tx.QueryRowContext(ctx, `
SELECT te.event_id FROM task_events te JOIN events e ON e.id = te.event_id
-WHERE te.task_id = ? AND te.delivery IN ('admitted', 'exposed')
- AND e.state = 'dispatched' AND e.content_dropped = 0 AND e.snapshot IS NOT NULL
+WHERE te.task_id = ? AND te.delivery IN ('admitted', 'exposed') AND `+servableSQL+`
ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return Instruction{}, false, nil
@@ -401,11 +439,7 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if err != nil {
return Instruction{}, false, err
}
- // A completed record is served again only to the worker it was already
- // exposed to; finished work is never handed out for the first time.
- servable := record.State == StateDispatched ||
- (record.State == StateCompleted && te.delivery != DeliveryAdmitted)
- if !servable || record.ContentDropped || len(record.Decision.Snapshot) == 0 {
+ if !servable(record, te.delivery) {
return Instruction{}, false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
}
@@ -463,6 +497,20 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
}, true, nil
}
+// servable is whether an event on a task is handed to its worker: its record
+// is dispatched, or completed after this worker was exposed to it — finished
+// work is never handed out for the first time — and it still has its
+// instruction. servableSQL is the same rule over task_events te and events e,
+// for the earliest-event query; the two are kept side by side so they cannot
+// drift.
+func servable(record Record, delivery Delivery) bool {
+ state := record.State == StateDispatched || (record.State == StateCompleted && delivery != DeliveryAdmitted)
+ return state && !record.ContentDropped && len(record.Decision.Snapshot) > 0
+}
+
+const servableSQL = `(e.state = 'dispatched' OR (e.state = 'completed' AND te.delivery <> 'admitted'))
+ AND e.content_dropped = 0 AND e.snapshot IS NOT NULL AND length(e.snapshot) > 0`
+
// Ack records the worker's acknowledgement: delivery moves to delivered, and
// ackID, when given, is the worker's own boost or comment. A repeat — a lost
// tool response retried — answers the same receipt.
@@ -661,161 +709,272 @@ func sameID(stored sql.NullInt64, given *int64) bool {
}
// StripMentionsOf removes every mention of personID from rich text, and
-// leaves every other attachment — other people's mentions, files — as it was.
-// A worker handed its own mention reads an instruction addressed to itself,
-// which says nothing the dispatch does not already say.
+// leaves the rest as it was. A worker handed its own mention reads an
+// instruction addressed to itself, which says nothing the dispatch does not
+// already say.
+//
+// What counts as a mention is exactly what basecamp.MentionedPersonIDs — the
+// reader admission decided the trigger with — counts: the tag walk below is
+// that reader's, rule for rule (comments, "" in a
-// quoted attribute does not end its tag, either quote style works, and the
-// sgid is entity-decoded before it is read. A mention element runs from its
-// start tag to the first closing tag, unless another attachment starts first
-// or none closes, in which case the start tag stands alone.
+// A mention element runs from its start tag to the first end tag of the same
+// name, unless another attachment starts first or none closes, in which case
+// the start tag stands alone.
func StripMentionsOf(richText string, personID int64) string {
- var out strings.Builder
+ if !slices.Contains(basecamp.MentionedPersonIDs(richText), personID) {
+ return richText
+ }
+ out, _ := stripOnce(richText, personID)
+ if slices.Contains(basecamp.MentionedPersonIDs(out), personID) {
+ // The walk is the reader's, so one pass removes every mention it
+ // reads; a mention left means removing one joined the text around it
+ // into another. Handing that out would put the agent's own mention in
+ // front of the worker, and handing out nothing would lose the
+ // instruction. The escaped text keeps the words and no markup.
+ return html.EscapeString(out)
+ }
+ return out
+}
+
+// stripOnce removes each mention element of personID the walk finds, and
+// returns the text and the removed spans, as offsets into text.
+func stripOnce(text string, personID int64) (string, [][2]int) {
+ var (
+ out strings.Builder
+ removed [][2]int
+ )
pos := 0
- for pos < len(richText) {
- t, ok := nextTag(richText, pos)
+ for pos < len(text) {
+ t, ok := nextMarkup(text, pos)
if !ok {
break
}
- out.WriteString(richText[pos:t.start])
- pos = t.end
- if strings.EqualFold(t.name, "bc-attachment") {
- if id, isPerson := basecamp.PersonIDFromSGID(html.UnescapeString(t.sgid)); isPerson && id == personID {
- pos = mentionEnd(richText, t.end)
+ if !t.isEnd && strings.EqualFold(t.name, "bc-attachment") {
+ if id, isPerson := basecamp.PersonIDFromSGID(t.sgid); isPerson && id == personID {
+ out.WriteString(text[pos:t.start])
+ pos = mentionEnd(text, t.end)
+ removed = append(removed, [2]int{t.start, pos})
continue
}
}
- out.WriteString(richText[t.start:t.end])
+ out.WriteString(text[pos:t.end])
+ pos = t.end
}
- out.WriteString(richText[pos:])
- return out.String()
+ out.WriteString(text[pos:])
+ return out.String(), removed
}
-// mentionEnd is where the mention whose start tag ends at from ends: after its
-// closing tag, or at from when another attachment starts first or none closes.
+// mentionEnd is where the mention whose start tag ends at from ends: after the
+// first , or at from when another attachment starts first or
+// none closes.
func mentionEnd(text string, from int) int {
for at := from; ; {
- t, ok := nextTag(text, at)
- if !ok || strings.EqualFold(t.name, "bc-attachment") {
+ t, ok := nextMarkup(text, at)
+ if !ok {
return from
}
- if strings.EqualFold(t.name, "/bc-attachment") {
- return t.end
+ if strings.EqualFold(t.name, "bc-attachment") {
+ if t.isEnd {
+ return t.end
+ }
+ return from
}
at = t.end
}
}
-// tag is one start or end tag: its bounds, its name ("/name" for an end tag)
-// and its sgid attribute, raw.
-type tag struct {
+// markup is one start or end tag the walk found: where it starts and ends,
+// its name, whether it is an end tag, and a start tag's first sgid, decoded.
+type markup struct {
start, end int
- name, sgid string
+ name string
+ isEnd bool
+ sgid string
}
-// nextTag finds the next complete tag at or after pos, skipping comments. ok
-// is false when none remains; a tag or comment left unterminated ends the
-// markup, as it does for a browser.
-func nextTag(text string, pos int) (tag, bool) {
+// nextMarkup returns the next start or end tag at or after pos, walking the
+// text as basecamp.MentionedPersonIDs does. ok is false when the markup ends:
+// no "<" left, or a comment, declaration or tag left unterminated, after
+// which nothing is markup.
+func nextMarkup(text string, pos int) (markup, bool) {
for pos < len(text) {
i := strings.IndexByte(text[pos:], '<')
if i < 0 {
- return tag{}, false
+ return markup{}, false
}
start := pos + i
- rest := text[start+1:]
- if strings.HasPrefix(rest, "!--") {
- stop := strings.Index(rest[3:], "-->")
+ pos = start + 1
+ rest := text[pos:]
+ switch {
+ case strings.HasPrefix(rest, "!--"):
+ stop := strings.Index(rest, "-->")
if stop < 0 {
- return tag{}, false
+ return markup{}, false
}
- pos = start + 1 + 3 + stop + 3
+ pos += stop + 3
+ continue
+ case strings.HasPrefix(rest, "/"):
+ stop := strings.IndexByte(rest, '>')
+ if stop < 0 {
+ return markup{}, false
+ }
+ nameEnd := 1
+ for nameEnd < len(rest) && isMarkupNameChar(rest[nameEnd]) {
+ nameEnd++
+ }
+ return markup{start: start, end: pos + stop + 1, name: rest[1:nameEnd], isEnd: true}, true
+ case strings.HasPrefix(rest, "!"), strings.HasPrefix(rest, "?"):
+ stop := strings.IndexByte(rest, '>')
+ if stop < 0 {
+ return markup{}, false
+ }
+ pos += stop + 1
continue
}
- n := 0
- if strings.HasPrefix(rest, "/") {
- n = 1
+ nameEnd := 0
+ for nameEnd < len(rest) && isMarkupNameChar(rest[nameEnd]) {
+ nameEnd++
}
- nameStart := n
- for n < len(rest) && isTagNameByte(rest[n]) {
- n++
+ if nameEnd == 0 {
+ continue // a bare "<" in text
}
- if n == nameStart {
- pos = start + 1
- continue
+ sgid, end, ok := scanAttributes(text, pos+nameEnd)
+ if !ok {
+ return markup{}, false
}
- t := tag{start: start, name: rest[:n]}
- at := start + 1 + n
- for at < len(text) {
- c := text[at]
- switch {
- case c == '>':
- t.end = at + 1
- return t, true
- case isTagNameByte(c):
- attrStart := at
- for at < len(text) && isTagNameByte(text[at]) {
- at++
- }
- attr := text[attrStart:at]
- for at < len(text) && isSpaceByte(text[at]) {
- at++
- }
- if at >= len(text) || text[at] != '=' {
- continue
- }
- at++
- for at < len(text) && isSpaceByte(text[at]) {
- at++
- }
- value, next := attributeValue(text, at)
- if next < 0 {
- return tag{}, false
+ return markup{start: start, end: end, name: rest[:nameEnd], sgid: sgid}, true
+ }
+ return markup{}, false
+}
+
+// isMarkupNameChar is what may follow "<" in a tag name: everything but space,
+// "/", ">", "<", "=" and quotes, so "' && c != '<' && c != '=' && c != '"' && c != '\''
+}
+
+func isMarkupSpace(c byte) bool {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
+}
+
+// scanAttributes walks a start tag's attributes from pos to its ">", and
+// returns the first sgid attribute's decoded value (empty when absent or
+// empty), the index after the ">", and whether the tag closed.
+func scanAttributes(text string, pos int) (sgid string, end int, ok bool) {
+ seen := false
+ for pos < len(text) {
+ for pos < len(text) && (isMarkupSpace(text[pos]) || text[pos] == '/') {
+ pos++
+ }
+ if pos >= len(text) {
+ return sgid, pos, false
+ }
+ if text[pos] == '>' {
+ return sgid, pos + 1, true
+ }
+ nameStart := pos
+ for pos < len(text) && !isMarkupSpace(text[pos]) && text[pos] != '=' && text[pos] != '>' && text[pos] != '/' {
+ pos++
+ }
+ name := text[nameStart:pos]
+ for pos < len(text) && isMarkupSpace(text[pos]) {
+ pos++
+ }
+ value := ""
+ if pos < len(text) && text[pos] == '=' {
+ pos++
+ for pos < len(text) && isMarkupSpace(text[pos]) {
+ pos++
+ }
+ if pos < len(text) && (text[pos] == '"' || text[pos] == '\'') {
+ quote := text[pos]
+ pos++
+ closing := strings.IndexByte(text[pos:], quote)
+ if closing < 0 {
+ return sgid, len(text), false
}
- if t.sgid == "" && strings.EqualFold(attr, "sgid") {
- t.sgid = value
+ value = text[pos : pos+closing]
+ pos += closing + 1
+ } else {
+ valueStart := pos
+ for pos < len(text) && !isMarkupSpace(text[pos]) && text[pos] != '>' {
+ pos++
}
- at = next
- default:
- at++
+ value = text[valueStart:pos]
}
}
- return tag{}, false
- }
- return tag{}, false
-}
-
-// attributeValue reads a quoted or bare attribute value at pos and returns it
-// with the position after it; next is -1 for an unterminated quote.
-func attributeValue(text string, pos int) (value string, next int) {
- if pos < len(text) && (text[pos] == '"' || text[pos] == '\'') {
- end := strings.IndexByte(text[pos+1:], text[pos])
- if end < 0 {
- return "", -1
+ if name == "" {
+ pos++
+ continue
+ }
+ if !seen && strings.EqualFold(name, "sgid") {
+ seen = true
+ sgid = html.UnescapeString(value)
}
- return text[pos+1 : pos+1+end], pos + end + 2
- }
- end := pos
- for end < len(text) && !isSpaceByte(text[end]) && text[end] != '>' {
- end++
}
- return text[pos:end], end
+ return sgid, pos, false
}
-func isTagNameByte(c byte) bool {
- return c == '-' || c == '_' || c == ':' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
+// StateDirName is the connector's state directory for one account and agent,
+// "-", inside StateRoot.
+func StateDirName(accountID string, agentID int64) string {
+ return accountID + "-" + strconv.FormatInt(agentID, 10)
}
-func isSpaceByte(c byte) bool {
- return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
+// ErrNotAStateDir is a directory that is not a connector state directory for
+// the account asked about.
+var ErrNotAStateDir = errors.New("not the connector's state directory for this account")
+
+// StateRoot is where every connector state directory lives:
+// $XDG_STATE_HOME/basecamp/connect, or ~/.local/state/basecamp/connect when
+// XDG_STATE_HOME is unset or not absolute, as the XDG specification says.
+func StateRoot() (string, error) {
+ base := os.Getenv("XDG_STATE_HOME")
+ if !filepath.IsAbs(base) {
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ return "", fmt.Errorf("connector: no state home: %w", err)
+ }
+ base = filepath.Join(home, ".local", "state")
+ }
+ return filepath.Join(filepath.Clean(base), "basecamp", "connect"), nil
}
-// StateDirName is the connector's state directory for one account and agent:
-// "-", under $XDG_STATE_HOME/basecamp/connect/.
-func StateDirName(accountID string, agentID int64) string {
- return accountID + "-" + strconv.FormatInt(agentID, 10)
+// ResolveStateDir is the one place a state directory is accepted: dir must
+// be exactly StateRoot/-, and its account must be
+// accountID, compared as numbers. It returns the agent's Person id.
+//
+// The location is part of the check, not only the name. A directory named
+// for this account anywhere else — a copy of another account's ledger renamed
+// to match — is refused, because the name is what binds a ledger to an
+// account and anyone can choose a name.
+func ResolveStateDir(dir, accountID string) (int64, error) {
+ root, err := StateRoot()
+ if err != nil {
+ return 0, err
+ }
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return 0, fmt.Errorf("connector: state directory %q: %w", dir, err)
+ }
+ if filepath.Dir(abs) != root {
+ return 0, fmt.Errorf("connector: %s is not inside %s: %w", abs, root, ErrNotAStateDir)
+ }
+ account, agent, ok := strings.Cut(filepath.Base(abs), "-")
+ agentID, err := strconv.ParseInt(agent, 10, 64)
+ if !ok || err != nil || agentID <= 0 {
+ return 0, fmt.Errorf("connector: %s is not named -: %w", abs, ErrNotAStateDir)
+ }
+ given, errGiven := strconv.ParseUint(account, 10, 64)
+ want, errWant := strconv.ParseUint(accountID, 10, 64)
+ if errGiven != nil || errWant != nil || given == 0 || given != want {
+ return 0, fmt.Errorf("connector: %s belongs to account %s, not %s: %w", abs, account, accountID, ErrNotAStateDir)
+ }
+ return agentID, nil
}
// LedgerFile is the ledger's file name inside the state directory.
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 65d5e1ea5..accad0679 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -471,6 +471,10 @@ func TestSupersedingReturnsUnexposedWork(t *testing.T) {
require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID), "a repeat is harmless")
assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State)
+ // The conversation is not free while event 1 waits for settlement.
+ _, err = f.ledger.CreateTask(ctx, []int64{2})
+ require.ErrorIs(t, err, ErrConversationBusy)
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
grant, err := f.ledger.CreateTask(ctx, []int64{2})
require.NoError(t, err)
d, err := f.ledger.Dispatch(ctx, grant.Token, adapterAgentID)
@@ -481,6 +485,67 @@ func TestSupersedingReturnsUnexposedWork(t *testing.T) {
assert.Equal(t, int64(2), got.EventID)
}
+// A conversation has one task at a time. Siblings a supersede returned wait
+// behind the exposed event a worker was handed, and two of them are never
+// launched as two tasks.
+func TestAConversationHasOneTaskAtATime(t *testing.T) {
+ ledger := newTestLedger(t)
+ ctx := context.Background()
+ const key = "recording:10304028989"
+ for _, id := range []int64{1, 2, 3} {
+ seenRecord(t, ledger, id)
+ _, err := ledger.Admission().Commit(ctx, admittedVerdict(id, 0, key))
+ require.NoError(t, err)
+ }
+ grant, err := ledger.CreateTask(ctx, []int64{1, 2, 3})
+ require.NoError(t, err)
+ d, err := ledger.Dispatch(ctx, grant.Token, adapterAgentID)
+ require.NoError(t, err)
+ _, _, err = d.Get(ctx, 1)
+ require.NoError(t, err)
+ require.NoError(t, ledger.SupersedeTask(ctx, grant.ID))
+
+ _, err = ledger.CreateTask(ctx, []int64{2})
+ require.ErrorIs(t, err, ErrConversationBusy, "event 1 was handed to a worker and is not settled")
+ _, err = ledger.CreateTask(ctx, []int64{2, 3})
+ require.ErrorIs(t, err, ErrConversationBusy)
+ assert.Equal(t, StateAdmitted, getRecord(t, ledger, 2).State, "a refused task moves nothing")
+
+ // A redispatch that takes the whole conversation is one task.
+ _, err = ledger.CreateTask(ctx, []int64{1, 2, 3})
+ require.NoError(t, err)
+
+ // Two separate launches on a free conversation: the second is refused.
+ other := newTestLedger(t)
+ for _, id := range []int64{1, 2} {
+ seenRecord(t, other, id)
+ _, err := other.Admission().Commit(ctx, admittedVerdict(id, 0, key))
+ require.NoError(t, err)
+ }
+ _, err = other.CreateTask(ctx, []int64{1})
+ require.NoError(t, err)
+ _, err = other.CreateTask(ctx, []int64{2})
+ require.ErrorIs(t, err, ErrConversationBusy)
+}
+
+// Asked for by id or as the earliest, an event is served by one rule.
+func TestTheEarliestAndAnExplicitGetAgree(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ // Settled elsewhere while the worker had it, before it acknowledged.
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+
+ byID, ok, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ require.True(t, ok)
+ earliest, ok, err := f.d.Get(ctx, 0)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, byID, earliest)
+}
+
// Finished work is never handed out for the first time: a record completed
// before this worker was exposed to it is refused, and one it completed
// itself is served again.
@@ -711,6 +776,7 @@ func TestStripMentionsOf(t *testing.T) {
"an entity in the sgid": {"a" + entityEncodedSGID(agent) + "b", "ab"},
"inside a comment it is text": {"" + other, "" + other},
"uppercase": {"a" + strings.ToUpper(agent[:14]) + agent[14:] + "b", "ab"},
+ "the first sgid is the one": {strings.Replace(agent, "`,
+ ``,
+ ``,
+ ``,
+ `>4])+string("0123456789abcdef"[a[0]&15])) + `;` + a[1:] + `">`,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ `" + other, "" + other},
- "uppercase": {"a" + strings.ToUpper(agent[:14]) + agent[14:] + "b", "ab"},
+ "uppercase": {"a" + strings.ToUpper(agent[:14]) + agent[14:] + "b", "a b"},
"the first sgid is the one": {strings.Replace(agent, "`,
}
}
@@ -142,6 +148,8 @@ func restoreSpan(input string, removed [][2]int, keep [2]int) string {
b.WriteString(input[pos:span[0]])
if span == keep {
b.WriteString(input[span[0]:span[1]])
+ } else {
+ b.WriteString(strippedMention)
}
pos = span[1]
}
From 5d8864cce4afdfd78f885002824ca12044f48b5d Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 10:08:16 +0200
Subject: [PATCH 08/33] Answer from the row, refuse with fields, name the
domain
get_dispatch built its answer from the row as it was read, before its own
writes; it now reads the row back and reports that. ResolveStateDir returns a
StateDirError carrying the directory, the root, the accounts and which rule
failed, so the command builds its message from fields rather than from the
error's text. The read-only refusal names the domain and the tool, which are
not the same name.
---
internal/commands/mcp.go | 18 ++++++-
internal/commands/mcp_connect_test.go | 13 ++---
internal/connector/ledger_dispatch.go | 59 ++++++++++++++++++++--
internal/connector/ledger_dispatch_test.go | 11 ++++
internal/mcpserver/connect_test.go | 2 +
internal/mcpserver/server.go | 2 +-
6 files changed, 92 insertions(+), 13 deletions(-)
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index f826f4455..fca123f13 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -126,6 +126,16 @@ func NewMCPCmd() *cobra.Command {
return cmd
}
+// stateDirHint says what the refused directory should have been.
+func stateDirHint(refusal *connector.StateDirError) string {
+ switch refusal.Why {
+ case connector.StateDirOtherAccount:
+ return fmt.Sprintf("It belongs to account %s; this server serves account %s.", refusal.Account, refusal.Want)
+ default:
+ return fmt.Sprintf("The connector's state directories live in %s, named -.", refusal.Root)
+ }
+}
+
// takeConnectTaskToken reads the task token and removes it from the
// environment, so nothing this process starts inherits it. That clears it from
// what the process hands on, not from its own /proc environ, which only this
@@ -151,7 +161,13 @@ func openConnectDispatch(ctx context.Context, stateDir, accountID, token string)
agentID, err := connector.ResolveStateDir(stateDir, accountID)
if err != nil {
- return nil, nil, output.ErrUsage(fmt.Sprintf("--connect-state: %s", strings.TrimPrefix(err.Error(), "connector: ")))
+ var refusal *connector.StateDirError
+ if errors.As(err, &refusal) {
+ return nil, nil, output.ErrUsageHint(
+ fmt.Sprintf("--connect-state %s is %s", refusal.Dir, refusal.Why),
+ stateDirHint(refusal))
+ }
+ return nil, nil, err
}
// The connector owns the ledger: a worker's server opens it as it is, and
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 424a65c11..5a183a8d9 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -183,12 +183,13 @@ func TestMCPCommandRefusesABadConnectState(t *testing.T) {
for name, tc := range map[string]struct {
dir, token, want string
}{
- "no token": {dir, "", connectTaskTokenEnv},
- "another account": {otherAccount, grant.Token, "belongs to account 1000"},
- "not a state dir": {notAStateDir, grant.Token, "not named -"},
- "outside the root": {elsewhere, grant.Token, "is not inside"},
- "no ledger": {empty, grant.Token, "no connector ledger"},
- "a token for no task": {dir, "not-a-task-token", "names no current task"},
+ "no token": {dir, "", connectTaskTokenEnv},
+ "another account": {otherAccount, grant.Token, "belongs to account 1000"},
+ "not a state dir": {notAStateDir, grant.Token, "not named -"},
+ "named for an agent that is not a number": {mkdir(filepath.Join(root, "999-abc")), grant.Token, "not named"},
+ "outside the root": {elsewhere, grant.Token, "outside the connector's state root"},
+ "no ledger": {empty, grant.Token, "no connector ledger"},
+ "a token for no task": {dir, "not-a-task-token", "names no current task"},
} {
t.Run(name, func(t *testing.T) {
t.Setenv(connectTaskTokenEnv, tc.token)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 562785399..dd63701b6 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -451,7 +451,7 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if _, err := tx.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = ? WHERE task_id = ? AND event_id = ? AND delivery = 'admitted'`, now, taskID, eventID); err != nil {
return Instruction{}, false, fmt.Errorf("connector: expose event %d: %w", eventID, err)
}
- te.delivery, wrote = DeliveryExposed, true
+ wrote = true
}
if te.guard == "armed" {
if _, err := tx.ExecContext(ctx, `UPDATE task_events SET guard = 'canceled' WHERE task_id = ? AND event_id = ? AND guard = 'armed'`, taskID, eventID); err != nil {
@@ -460,6 +460,12 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
wrote = true
}
if wrote {
+ // Read back what the writes left rather than what was read before
+ // them: the instruction reports the row, not this call's expectation
+ // of it.
+ if te, err = loadTaskEvent(ctx, tx, taskID, eventID); err != nil {
+ return Instruction{}, false, err
+ }
if err := tx.Commit(); err != nil {
return Instruction{}, false, fmt.Errorf("connector: commit get_dispatch: %w", err)
}
@@ -934,9 +940,49 @@ func StateDirName(accountID string, agentID int64) string {
}
// ErrNotAStateDir is a directory that is not a connector state directory for
-// the account asked about.
+// the account asked about. StateDirError carries why.
var ErrNotAStateDir = errors.New("not the connector's state directory for this account")
+// StateDirError says which rule a state directory failed, in fields a caller
+// can build its own message from rather than by reading this one.
+type StateDirError struct {
+ // Dir is the directory as given, made absolute.
+ Dir string
+ // Root is the connector's state root, the only place a state directory
+ // lives.
+ Root string
+ // Account is the account the directory names, empty when it names none;
+ // Want is the account it had to name.
+ Account, Want string
+ // Why is the rule it failed.
+ Why StateDirProblem
+}
+
+// StateDirProblem is why a state directory was refused.
+type StateDirProblem string
+
+const (
+ // StateDirElsewhere is a directory outside the state root.
+ StateDirElsewhere StateDirProblem = "outside the connector's state root"
+ // StateDirMisnamed is a directory not named -.
+ StateDirMisnamed StateDirProblem = "not named -"
+ // StateDirOtherAccount is another account's state directory.
+ StateDirOtherAccount StateDirProblem = "another account's"
+)
+
+func (e *StateDirError) Error() string {
+ switch e.Why {
+ case StateDirElsewhere:
+ return fmt.Sprintf("%s is not inside %s", e.Dir, e.Root)
+ case StateDirOtherAccount:
+ return fmt.Sprintf("%s belongs to account %s, not %s", e.Dir, e.Account, e.Want)
+ default:
+ return fmt.Sprintf("%s is not named -", e.Dir)
+ }
+}
+
+func (e *StateDirError) Unwrap() error { return ErrNotAStateDir }
+
// StateRoot is where every connector state directory lives:
// $XDG_STATE_HOME/basecamp/connect, or ~/.local/state/basecamp/connect when
// XDG_STATE_HOME is unset or not absolute, as the XDG specification says.
@@ -969,18 +1015,21 @@ func ResolveStateDir(dir, accountID string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("connector: state directory %q: %w", dir, err)
}
+ refuse := func(why StateDirProblem, account string) (int64, error) {
+ return 0, &StateDirError{Dir: abs, Root: root, Account: account, Want: accountID, Why: why}
+ }
if filepath.Dir(abs) != root {
- return 0, fmt.Errorf("connector: %s is not inside %s: %w", abs, root, ErrNotAStateDir)
+ return refuse(StateDirElsewhere, "")
}
account, agent, ok := strings.Cut(filepath.Base(abs), "-")
agentID, err := strconv.ParseInt(agent, 10, 64)
if !ok || err != nil || agentID <= 0 {
- return 0, fmt.Errorf("connector: %s is not named -: %w", abs, ErrNotAStateDir)
+ return refuse(StateDirMisnamed, account)
}
given, errGiven := strconv.ParseUint(account, 10, 64)
want, errWant := strconv.ParseUint(accountID, 10, 64)
if errGiven != nil || errWant != nil || given == 0 || given != want {
- return 0, fmt.Errorf("connector: %s belongs to account %s, not %s: %w", abs, account, accountID, ErrNotAStateDir)
+ return refuse(StateDirOtherAccount, account)
}
return agentID, nil
}
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 4f3c55902..e95fb52ad 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -821,6 +821,17 @@ func TestResolveStateDirAcceptsOnlyTheCanonicalDirectory(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := ResolveStateDir(dir, "999")
assert.ErrorIs(t, err, ErrNotAStateDir)
+ var refusal *StateDirError
+ require.ErrorAs(t, err, &refusal, "the refusal says why in fields, not in a message to be parsed")
+ assert.Equal(t, root, refusal.Root)
+ assert.Equal(t, "999", refusal.Want)
+ if name == "another account" {
+ assert.Equal(t, StateDirOtherAccount, refusal.Why)
+ assert.Equal(t, "1000", refusal.Account)
+ }
+ if name == "outside the root" {
+ assert.Equal(t, StateDirElsewhere, refusal.Why)
+ }
})
}
diff --git a/internal/mcpserver/connect_test.go b/internal/mcpserver/connect_test.go
index 2c70c2e90..8291ca4ff 100644
--- a/internal/mcpserver/connect_test.go
+++ b/internal/mcpserver/connect_test.go
@@ -117,6 +117,8 @@ func TestTheConnectDomainSurvivesNarrowing(t *testing.T) {
func TestTheConnectDomainIsNeverReadOnly(t *testing.T) {
_, err := New(newTestAPI(noUpstream(t)), Config{Connect: &fakeDispatch{}, ReadOnly: true})
require.Error(t, err)
+ assert.Contains(t, err.Error(), "the "+connectDomainKey+" domain ("+connectToolName+")",
+ "the refusal names the domain and the tool it is served as, which are not the same name")
}
func TestGetDispatchOverMCP(t *testing.T) {
diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go
index e03aa01fa..d30e23487 100644
--- a/internal/mcpserver/server.go
+++ b/internal/mcpserver/server.go
@@ -53,7 +53,7 @@ func New(api API, cfg Config) (*Server, error) {
if cfg.ReadOnly {
// Every connect action records something; a read-only server
// would serve the domain with nothing in it.
- return nil, fmt.Errorf("the %s domain cannot be served read-only", connectToolName)
+ return nil, fmt.Errorf("the %s domain (%s) cannot be served read-only", connectDomainKey, connectToolName)
}
for _, d := range cat.Domains {
if d.Key == connectDomainKey {
From ff6fc5d2b7da605f9d8760523eec44a9c3078f81 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 10:30:40 +0200
Subject: [PATCH 09/33] Take links as a list of strings from any caller
A call over the wire brings []any, because that is what JSON decodes to; a
caller building arguments in process has a []string. Both are the same list.
---
internal/mcpserver/connect.go | 43 +++++++++++++++++++++---------
internal/mcpserver/connect_test.go | 10 +++++++
2 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go
index ad4b69064..3bfcfb71c 100644
--- a/internal/mcpserver/connect.go
+++ b/internal/mcpserver/connect.go
@@ -3,6 +3,7 @@ package mcpserver
import (
"context"
"errors"
+ "fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -176,19 +177,9 @@ func handleCompleteDispatch(ctx context.Context, d Dispatch, params map[string]a
if err != nil {
return gateway.ErrorResult("%v", err), nil
}
- var links []string
- if raw, ok := params["links"]; ok && raw != nil {
- items, ok := raw.([]any)
- if !ok {
- return gateway.ErrorResult("parameter %q must be an array of strings", "links"), nil
- }
- for _, item := range items {
- link, ok := item.(string)
- if !ok {
- return gateway.ErrorResult("parameter %q must be an array of strings", "links"), nil
- }
- links = append(links, link)
- }
+ links, err := stringList(params, "links")
+ if err != nil {
+ return gateway.ErrorResult("%v", err), nil
}
receipt, err := d.Complete(ctx, eventID, connector.Completion{Outcome: connector.Outcome(outcome), Links: links, ReplyID: replyID})
if err != nil {
@@ -197,6 +188,32 @@ func handleCompleteDispatch(ctx context.Context, d Dispatch, params map[string]a
return gateway.JSONResult(receipt)
}
+// stringList reads an array of strings. A call over the wire brings []any,
+// because that is what JSON decodes to; a caller building arguments in
+// process has a []string in hand, and there is no reason to refuse it.
+func stringList(params map[string]any, name string) ([]string, error) {
+ raw, ok := params[name]
+ if !ok || raw == nil {
+ return nil, nil
+ }
+ switch values := raw.(type) {
+ case []string:
+ return values, nil
+ case []any:
+ list := make([]string, 0, len(values))
+ for _, item := range values {
+ value, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("parameter %q must be an array of strings, got a %T in it", name, item)
+ }
+ list = append(list, value)
+ }
+ return list, nil
+ default:
+ return nil, fmt.Errorf("parameter %q must be an array of strings, got %T", name, raw)
+ }
+}
+
func optionalID(params map[string]any, name string) (*int64, error) {
if raw, ok := params[name]; !ok || raw == nil {
return nil, nil
diff --git a/internal/mcpserver/connect_test.go b/internal/mcpserver/connect_test.go
index 8291ca4ff..fd3caa6e2 100644
--- a/internal/mcpserver/connect_test.go
+++ b/internal/mcpserver/connect_test.go
@@ -174,6 +174,16 @@ func TestAckAndCompleteOverMCP(t *testing.T) {
assert.True(t, isError, "outcome is required")
_, isError = s.call(completeDispatch, map[string]any{"event_id": 7, "outcome": "succeeded", "links": []any{1}})
assert.True(t, isError, "links are strings")
+
+ // In process, a caller has a []string in hand; over the wire, JSON makes
+ // []any. Both are the same list.
+ res, err := handleCompleteDispatch(context.Background(), d, map[string]any{
+ "event_id": 7, "outcome": "failed", "links": []string{"https://example.com/a"},
+ })
+ require.NoError(t, err)
+ require.False(t, res.IsError)
+ require.Len(t, d.completes, 2)
+ assert.Equal(t, []string{"https://example.com/a"}, d.completes[1].Links)
}
func TestConnectRefusalsAreNamed(t *testing.T) {
From 3f6c7bf0fcfdad43778d469cef446beb8cb31485 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 10:52:59 +0200
Subject: [PATCH 10/33] Build the instruction with the ledger free
get_dispatch decoded the snapshot and stripped the agent's mention with its
transaction still open, and every transaction here takes the write lock as it
opens, so a worker's read blocked the connector for the length of that work.
The transaction now ends as soon as the ledger work is done. A test writes
from a second connection while the instruction is being built.
---
internal/connector/ledger_dispatch.go | 15 +++++++++
internal/connector/ledger_dispatch_test.go | 37 ++++++++++++++++++++--
internal/mcpserver/connect.go | 20 ++++++------
internal/mcpserver/connect_test.go | 14 ++++----
4 files changed, 67 insertions(+), 19 deletions(-)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index dd63701b6..5c41db2b3 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -286,6 +286,10 @@ type TaskDispatch struct {
ledger *Ledger
hash string
agentID int64
+
+ // afterTx runs when a call has closed its transaction and is building its
+ // answer. A test seam: it is where the ledger must already be free.
+ afterTx func()
}
// Dispatch binds the ledger to a worker's task token, refusing one that names
@@ -407,6 +411,8 @@ type taskEvent struct {
replyID sql.NullInt64
}
+// get is one get_dispatch: the ledger work in a transaction, the instruction
+// built once it is closed.
func (d *TaskDispatch) get(ctx context.Context, eventID int64) (Instruction, bool, error) {
l := d.ledger
tx, err := l.db.BeginTx(ctx, nil)
@@ -469,6 +475,15 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if err := tx.Commit(); err != nil {
return Instruction{}, false, fmt.Errorf("connector: commit get_dispatch: %w", err)
}
+ } else if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
+ return Instruction{}, false, fmt.Errorf("connector: end get_dispatch: %w", err)
+ }
+ // The transaction is over. Every transaction here takes the write lock as
+ // it opens, so decoding the snapshot and stripping the agent's mention —
+ // the only work in this call that is not a query — happens with the
+ // ledger free for the connector and for other workers.
+ if d.afterTx != nil {
+ d.afterTx()
}
var snapshot struct {
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index e95fb52ad..4446f41a5 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -25,13 +25,17 @@ const otherPersonID int64 = 1001
// the task.
type dispatchFixture struct {
ledger *Ledger
+ path string
grant TaskGrant
d *TaskDispatch
}
func newDispatchFixture(t *testing.T) dispatchFixture {
t.Helper()
- ledger := newTestLedger(t)
+ path := filepath.Join(t.TempDir(), "state", "connector.db")
+ ledger, err := OpenLedger(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ledger.Close() })
ctx := context.Background()
for _, id := range []int64{1, 2} {
seenRecord(t, ledger, id)
@@ -45,7 +49,7 @@ func newDispatchFixture(t *testing.T) dispatchFixture {
require.NoError(t, err)
d, err := ledger.Dispatch(ctx, grant.Token, adapterAgentID)
require.NoError(t, err)
- return dispatchFixture{ledger: ledger, grant: grant, d: d}
+ return dispatchFixture{ledger: ledger, path: path, grant: grant, d: d}
}
type taskEventRow struct {
@@ -840,3 +844,32 @@ func TestResolveStateDirAcceptsOnlyTheCanonicalDirectory(t *testing.T) {
require.NoError(t, err)
assert.True(t, filepath.IsAbs(root), "a relative XDG_STATE_HOME is ignored, as the specification says")
}
+
+// The ledger is free while a call builds its answer: every transaction here
+// takes the write lock as it opens, and decoding the snapshot and stripping
+// the agent's mention must not be done holding it.
+func TestGetDispatchDoesNotHoldTheLedgerWhileItBuildsItsAnswer(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ other, err := OpenExistingLedger(ctx, f.path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = other.Close() })
+
+ writes := 0
+ f.d.afterTx = func() {
+ // Intake, writing while the worker builds its instruction. It waits
+ // for the write lock, so a transaction still open here fails this
+ // (after the busy timeout) rather than deadlocking.
+ writes++
+ fresh, err := other.RecordSeen(ctx, testEvent(int64(100+writes)), LanePoll)
+ require.NoError(t, err)
+ require.True(t, fresh)
+ }
+
+ // The call that writes (exposure), and the repeat that writes nothing.
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ assert.Equal(t, 2, writes)
+}
diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go
index 3bfcfb71c..3393ad45c 100644
--- a/internal/mcpserver/connect.go
+++ b/internal/mcpserver/connect.go
@@ -23,12 +23,12 @@ import (
// Nothing it returns carries the token, a feed position or a route; the
// instruction is an allowlist of fields (connector.Instruction).
const (
- connectDomainKey = "connect"
- connectToolName = "basecamp_connect"
- getDispatchAction = "get_dispatch"
- ackDispatchAction = "ack_dispatch"
- completeDispatch = "complete_dispatch"
- connectDomainBlurb = "Your dispatch from the Basecamp agent connector: pull the instruction you were started for, acknowledge it, and report its outcome. Bound to this task; there is no listing."
+ connectDomainKey = "connect"
+ connectToolName = "basecamp_connect"
+ getDispatchAction = "get_dispatch"
+ ackDispatchAction = "ack_dispatch"
+ completeDispatchAction = "complete_dispatch"
+ connectDomainBlurb = "Your dispatch from the Basecamp agent connector: pull the instruction you were started for, acknowledge it, and report its outcome. Bound to this task; there is no listing."
)
// Dispatch is the task-bound ledger the connect domain serves.
@@ -70,7 +70,7 @@ func connectDomain() *catalog.Domain {
},
{
ID: "CompleteDispatch",
- Action: completeDispatch,
+ Action: completeDispatchAction,
Tag: "Connect",
Summary: syntheticSummaryTag + "report the outcome of an instruction: succeeded or failed, with your reply's id and any links (a pull request, a card). Also acknowledges it. " +
"A repeat of the same report answers the same receipt; a different report is refused, because a reported outcome stands.",
@@ -107,9 +107,9 @@ func connectDomain() *catalog.Domain {
type connectHandler func(ctx context.Context, d Dispatch, params map[string]any) (*mcp.CallToolResult, error)
var connectHandlers = map[string]connectHandler{
- getDispatchAction: handleGetDispatch,
- ackDispatchAction: handleAckDispatch,
- completeDispatch: handleCompleteDispatch,
+ getDispatchAction: handleGetDispatch,
+ ackDispatchAction: handleAckDispatch,
+ completeDispatchAction: handleCompleteDispatch,
}
func (d dispatcher) handleConnect(ctx context.Context, op *catalog.Operation, params map[string]any) (*mcp.CallToolResult, error) {
diff --git a/internal/mcpserver/connect_test.go b/internal/mcpserver/connect_test.go
index fd3caa6e2..1e5b7f9b7 100644
--- a/internal/mcpserver/connect_test.go
+++ b/internal/mcpserver/connect_test.go
@@ -100,8 +100,8 @@ func TestTheConnectDomainExistsOnlyWhenConfigured(t *testing.T) {
actions = append(actions, name)
}
}
- assert.Equal(t, []string{ackDispatchAction, completeDispatch, getDispatchAction}, actions,
- "exactly these three: a worker never reads other tasks")
+ assert.ElementsMatch(t, []string{ackDispatchAction, completeDispatchAction, getDispatchAction}, actions,
+ "exactly these three, in whatever order the gateway lists them: a worker never reads other tasks")
}
// A server narrowed with --domains still serves the task's own domain.
@@ -161,7 +161,7 @@ func TestAckAndCompleteOverMCP(t *testing.T) {
_, isError = s.call(ackDispatchAction, map[string]any{})
assert.True(t, isError, "event_id is required")
- text, isError = s.call(completeDispatch, map[string]any{
+ text, isError = s.call(completeDispatchAction, map[string]any{
"event_id": 7, "outcome": "succeeded", "links": []any{"https://example.com/pr"}, "reply_id": 100,
})
require.False(t, isError, text)
@@ -170,9 +170,9 @@ func TestAckAndCompleteOverMCP(t *testing.T) {
assert.Equal(t, []string{"https://example.com/pr"}, d.completes[0].Links)
require.NotNil(t, d.completes[0].ReplyID)
- _, isError = s.call(completeDispatch, map[string]any{"event_id": 7})
+ _, isError = s.call(completeDispatchAction, map[string]any{"event_id": 7})
assert.True(t, isError, "outcome is required")
- _, isError = s.call(completeDispatch, map[string]any{"event_id": 7, "outcome": "succeeded", "links": []any{1}})
+ _, isError = s.call(completeDispatchAction, map[string]any{"event_id": 7, "outcome": "succeeded", "links": []any{1}})
assert.True(t, isError, "links are strings")
// In process, a caller has a []string in hand; over the wire, JSON makes
@@ -203,7 +203,7 @@ func TestConnectRefusalsAreNamed(t *testing.T) {
}{
{getDispatchAction, nil},
{ackDispatchAction, map[string]any{"event_id": 7}},
- {completeDispatch, map[string]any{"event_id": 7, "outcome": "failed"}},
+ {completeDispatchAction, map[string]any{"event_id": 7, "outcome": "failed"}},
} {
text, isError := s.call(call.action, call.params)
assert.True(t, isError)
@@ -219,7 +219,7 @@ func TestConnectRefusalsAreNamed(t *testing.T) {
assert.Contains(t, text, "connector ledger could not answer")
s = connectSession(t, &fakeDispatch{err: fmt.Errorf("connector: link %q is not an http(s) URL: %w", "ftp://x", connector.ErrInvalidReport)})
- text, isError = s.call(completeDispatch, map[string]any{"event_id": 7, "outcome": "failed"})
+ text, isError = s.call(completeDispatchAction, map[string]any{"event_id": 7, "outcome": "failed"})
assert.True(t, isError)
assert.Contains(t, text, "ftp://x", "what the worker got wrong is said")
}
From 67aac1d15759d1765aa3b1d5b9c34c9b4ba57926 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 11:18:24 +0200
Subject: [PATCH 11/33] Write the dispatch lifecycle down, enforce it in one
place, try every edge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review kept finding the same class: a transition nobody had decided was
allowed. The lifecycle is now one table at the top of ledger_dispatch.go —
record, task, delivery and guard states, every transition, who performs it,
and the invariants across them — and TestDispatchLifecycleTable tries every
pair of every table, allowed and refused.
What the table added: a record a worker was handed leaves dispatched only to
completed (move refuses it with ErrHeldByWorker, and a trigger refuses it for
any writer), so one task per conversation survives any later lifecycle
change; delivery never skips exposure; a guard settles once. Superseding
checks its row scan before retiring, and acknowledgements and completions end
their transaction before building the receipt, as get does.
The task token no longer comes from the environment. basecamp mcp reads it
from an inherited pipe or socket named by --connect-token-fd, closes it before
authentication, and refuses a descriptor that is anything else, standard I/O,
or a token left in $BASECAMP_CONNECT_TASK_TOKEN.
---
.surface | 1 +
internal/commands/fcntl_unix_test.go | 7 +
internal/commands/mcp.go | 96 +++--
internal/commands/mcp_connect_test.go | 45 ++-
.../commands/mcp_connect_token_unix_test.go | 138 +++++++
internal/commands/mcp_test.go | 33 +-
internal/connector/dispatch_lifecycle_test.go | 336 ++++++++++++++++++
internal/connector/ledger.go | 27 +-
internal/connector/ledger_dispatch.go | 95 +++++
internal/connector/ledger_dispatch_test.go | 69 +++-
internal/connector/ledger_events.go | 20 ++
11 files changed, 812 insertions(+), 55 deletions(-)
create mode 100644 internal/commands/fcntl_unix_test.go
create mode 100644 internal/commands/mcp_connect_token_unix_test.go
create mode 100644 internal/connector/dispatch_lifecycle_test.go
diff --git a/.surface b/.surface
index 6d34a21ee..7234198be 100644
--- a/.surface
+++ b/.surface
@@ -11043,6 +11043,7 @@ FLAG basecamp mcp --account type=string
FLAG basecamp mcp --agent type=bool
FLAG basecamp mcp --cache-dir type=string
FLAG basecamp mcp --connect-state type=string
+FLAG basecamp mcp --connect-token-fd type=int
FLAG basecamp mcp --count type=bool
FLAG basecamp mcp --domains type=stringSlice
FLAG basecamp mcp --help type=bool
diff --git a/internal/commands/fcntl_unix_test.go b/internal/commands/fcntl_unix_test.go
new file mode 100644
index 000000000..20512a9f3
--- /dev/null
+++ b/internal/commands/fcntl_unix_test.go
@@ -0,0 +1,7 @@
+//go:build unix
+
+package commands
+
+import "golang.org/x/sys/unix"
+
+func fcntlGetFD(fd int) (int, error) { return unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) }
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index fca123f13..0bc506150 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "io"
"log/slog"
"os"
"os/signal"
@@ -24,17 +25,21 @@ import (
// transports instead of the process's stdin/stdout.
var mcpTransport = func() mcp.Transport { return &mcp.StdioTransport{} }
-// connectTaskTokenEnv carries a connector-started worker's task token. The
-// token binds the basecamp_connect domain to one task, so it is taken from the
-// environment the connector sets for the server and never from a flag, which
-// any process on the machine can read from the command line.
+// connectTaskTokenEnv is where an earlier draft of the connector put a
+// worker's task token. It is not a way in: a token found there is removed and
+// the server refuses to start, so nothing is led to hand it over that way.
const connectTaskTokenEnv = "BASECAMP_CONNECT_TASK_TOKEN"
+// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
+// 43 characters; anything near this is not one.
+const maxTaskTokenBytes = 4096
+
// NewMCPCmd creates the mcp command serving Basecamp over MCP on stdio.
func NewMCPCmd() *cobra.Command {
var readOnly bool
var domains []string
var connectState string
+ var connectTokenFD int
cmd := &cobra.Command{
Use: "mcp",
@@ -58,17 +63,29 @@ func NewMCPCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
- // The task token is taken out of the environment before anything
- // else runs: authentication can start helper processes, and a
- // child started then would inherit it.
+ // The task token is read, and its descriptor closed, before
+ // anything else runs: authentication can start helper processes,
+ // and a child started then would inherit an open descriptor.
var taskToken string
- if connectState != "" {
+ switch {
+ case connectState == "" && connectTokenFD >= 0:
+ return output.ErrUsage("--connect-token-fd is only for a server started with --connect-state")
+ case connectState != "":
if readOnly {
// Every connect action records something; refused before
// the token or the ledger is touched.
return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
}
- taskToken = takeConnectTaskToken()
+ if _, set := os.LookupEnv(connectTaskTokenEnv); set {
+ _ = os.Unsetenv(connectTaskTokenEnv)
+ return output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
+ "Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
+ }
+ token, err := readTaskToken(connectTokenFD)
+ if err != nil {
+ return err
+ }
+ taskToken = token
}
// CheckAuthenticated, not IsAuthenticated: this refuses to
@@ -121,7 +138,8 @@ func NewMCPCmd() *cobra.Command {
cmd.Flags().BoolVar(&readOnly, "read-only", false, "Serve only read-only actions")
cmd.Flags().StringSliceVar(&domains, "domains", nil, "Narrow to specific domains (comma-separated; default all)")
- cmd.Flags().StringVar(&connectState, "connect-state", "", "Serve the basecamp_connect domain from this connector state directory, for the task named by $"+connectTaskTokenEnv)
+ cmd.Flags().StringVar(&connectState, "connect-state", "", "Serve the basecamp_connect domain from this connector state directory, for the task whose token arrives on --connect-token-fd")
+ cmd.Flags().IntVar(&connectTokenFD, "connect-token-fd", -1, "Read the task token from this inherited file descriptor (3 or above), then close it")
return cmd
}
@@ -136,14 +154,51 @@ func stateDirHint(refusal *connector.StateDirError) string {
}
}
-// takeConnectTaskToken reads the task token and removes it from the
-// environment, so nothing this process starts inherits it. That clears it from
-// what the process hands on, not from its own /proc environ, which only this
-// user can read.
-func takeConnectTaskToken() string {
- token := os.Getenv(connectTaskTokenEnv)
- _ = os.Unsetenv(connectTaskTokenEnv)
- return token
+// readTaskToken reads the task token from an inherited descriptor and closes
+// it. The connector hands the token over as the read end of a pipe, so it never
+// exists at a path, in argv or in the environment; once read, the descriptor
+// is gone too, and nothing this process starts can inherit it.
+//
+// Descriptors 0 to 2 are refused: stdin and stdout are the MCP wire and stderr
+// is the log.
+func readTaskToken(fd int) (string, error) {
+ switch {
+ case fd < 0:
+ return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
+ case fd < 3:
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be 3 or above", fd))
+ }
+ file := os.NewFile(uintptr(fd), "connect-token")
+ if file == nil {
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is not a descriptor", fd))
+ }
+ // Only a pipe or a socket is taken, and anything else is left exactly as
+ // it was — not read, not closed. A regular file would be the token at a
+ // path, and a wrong number could name a descriptor this process already
+ // uses for something else.
+ info, err := file.Stat()
+ if err != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: it is not open", fd))
+ }
+ if info.Mode()&(os.ModeNamedPipe|os.ModeSocket) == 0 {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d is not a pipe or a socket; the task token is handed over on one, never from a file", fd))
+ }
+ data, readErr := io.ReadAll(io.LimitReader(file, maxTaskTokenBytes+1))
+ closeErr := file.Close()
+ if readErr != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, readErr))
+ }
+ if closeErr != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, closeErr))
+ }
+ if len(data) > maxTaskTokenBytes {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d carries more than %d bytes; that is not a task token", fd, maxTaskTokenBytes))
+ }
+ token := strings.TrimSpace(string(data))
+ if token == "" {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d carried an empty task token", fd))
+ }
+ return token, nil
}
// openConnectDispatch opens the connector's ledger in stateDir and binds it to
@@ -155,9 +210,6 @@ func takeConnectTaskToken() string {
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
- if strings.TrimSpace(token) == "" {
- return nil, nil, output.ErrUsage("--connect-state needs the task token in $" + connectTaskTokenEnv + "; the connector sets it when it starts a worker")
- }
agentID, err := connector.ResolveStateDir(stateDir, accountID)
if err != nil {
@@ -183,7 +235,7 @@ func openConnectDispatch(ctx context.Context, stateDir, accountID, token string)
if err != nil {
_ = ledger.Close()
if errors.Is(err, connector.ErrTaskTokenRefused) {
- return nil, nil, output.ErrUsage("$" + connectTaskTokenEnv + " names no current task in " + stateDir)
+ return nil, nil, output.ErrUsage("the task token names no current task in " + stateDir)
}
return nil, nil, err
}
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 5a183a8d9..18c76d102 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -1,3 +1,5 @@
+//go:build unix
+
package commands
import (
@@ -7,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"testing"
"time"
@@ -93,11 +96,9 @@ func connectMCPApp(t *testing.T, accountID, baseURL string) (*appctx.App, string
// server started without the token does not expose it.
func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) {
app, dir, grant, ledger := connectMCPApp(t, "999", unusedUpstream(t).URL)
- t.Setenv(connectTaskTokenEnv, grant.Token)
- session := runMCPCommandWithApp(t, app, "--connect-state", dir)
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, grant.Token)))
assert.Contains(t, toolNames(t, session), "basecamp_connect")
- assert.Empty(t, os.Getenv(connectTaskTokenEnv), "the token does not outlive startup in the environment")
res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "basecamp_connect", Arguments: map[string]any{"action": "get_dispatch"},
@@ -126,41 +127,44 @@ func TestMCPCommandServesTheConnectDomainFromTheLedger(t *testing.T) {
func TestMCPCommandMatchesTheAccountAsANumber(t *testing.T) {
app, dir, grant, _ := connectMCPApp(t, "0999", unusedUpstream(t).URL)
- t.Setenv(connectTaskTokenEnv, grant.Token)
- session := runMCPCommandWithApp(t, app, "--connect-state", dir+"/")
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir+"/", "--connect-token-fd", strconv.Itoa(tokenPipe(t, grant.Token)))
assert.Contains(t, toolNames(t, session), "basecamp_connect")
}
-// Authentication can start helper processes, so the token is out of the
-// environment before it runs — even when it then fails.
+// Authentication can start helper processes, so the token is read and its
+// descriptor closed before it runs — even when it then fails.
func TestMCPCommandTakesTheTokenBeforeAuthenticating(t *testing.T) {
app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
t.Setenv("BASECAMP_TOKEN", "")
- t.Setenv(connectTaskTokenEnv, grant.Token)
+ fd := tokenPipe(t, grant.Token)
+ dev, ino, _ := fdIdentity(t, fd)
- err := executeMCPCommand(t, app, "--connect-state", dir)
+ err := executeMCPCommand(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd))
require.Error(t, err)
assert.Contains(t, err.Error(), "Not authenticated")
- assert.Empty(t, os.Getenv(connectTaskTokenEnv))
+ if nowDev, nowIno, open := fdIdentity(t, fd); open {
+ assert.False(t, nowDev == dev && nowIno == ino, "the token descriptor was closed before authentication")
+ }
}
func TestMCPCommandRefusesReadOnlyBeforeTouchingTheToken(t *testing.T) {
app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
- t.Setenv(connectTaskTokenEnv, grant.Token)
+ fd := tokenPipe(t, grant.Token)
+ dev, ino, _ := fdIdentity(t, fd)
- err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only")
+ err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only", "--connect-token-fd", strconv.Itoa(fd))
require.Error(t, err)
assert.Contains(t, err.Error(), "read-only")
- assert.Equal(t, grant.Token, os.Getenv(connectTaskTokenEnv))
+ nowDev, nowIno, open := fdIdentity(t, fd)
+ assert.True(t, open && nowDev == dev && nowIno == ino, "the descriptor was not touched")
}
func TestMCPCommandWithoutConnectStateHasNoConnectDomain(t *testing.T) {
- app, _, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
- t.Setenv(connectTaskTokenEnv, grant.Token)
+ app, _, _, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
session := runMCPCommandWithApp(t, app)
- assert.NotContains(t, toolNames(t, session), "basecamp_connect", "a token alone serves nothing")
+ assert.NotContains(t, toolNames(t, session), "basecamp_connect")
}
func TestMCPCommandRefusesABadConnectState(t *testing.T) {
@@ -183,7 +187,7 @@ func TestMCPCommandRefusesABadConnectState(t *testing.T) {
for name, tc := range map[string]struct {
dir, token, want string
}{
- "no token": {dir, "", connectTaskTokenEnv},
+ "no token": {dir, "", "--connect-token-fd"},
"another account": {otherAccount, grant.Token, "belongs to account 1000"},
"not a state dir": {notAStateDir, grant.Token, "not named -"},
"named for an agent that is not a number": {mkdir(filepath.Join(root, "999-abc")), grant.Token, "not named"},
@@ -192,8 +196,11 @@ func TestMCPCommandRefusesABadConnectState(t *testing.T) {
"a token for no task": {dir, "not-a-task-token", "names no current task"},
} {
t.Run(name, func(t *testing.T) {
- t.Setenv(connectTaskTokenEnv, tc.token)
- err := executeMCPCommand(t, app, "--connect-state", tc.dir)
+ args := []string{"--connect-state", tc.dir}
+ if tc.token != "" {
+ args = append(args, "--connect-token-fd", strconv.Itoa(tokenPipe(t, tc.token)))
+ }
+ err := executeMCPCommand(t, app, args...)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
})
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
new file mode 100644
index 000000000..3d022b357
--- /dev/null
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -0,0 +1,138 @@
+//go:build unix
+
+package commands
+
+import (
+ "bytes"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// tokenPipe hands the token over the way the connector does: the read end of
+// a pipe the child inherits, the write end written and closed. It returns the
+// descriptor number to pass, which is the command's to close.
+func tokenPipe(t *testing.T, token string) int {
+ t.Helper()
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ _, err = w.WriteString(token)
+ require.NoError(t, err)
+ require.NoError(t, w.Close())
+ // A descriptor of its own, so the test's *os.File never closes the one
+ // the command is handed.
+ fd, err := syscall.Dup(int(r.Fd()))
+ require.NoError(t, err)
+ require.NoError(t, r.Close())
+ return fd
+}
+
+func fdOpen(fd int) bool {
+ _, err := fcntlGetFD(fd)
+ return err == nil
+}
+
+// fdIdentity is what a descriptor refers to. A closed number is reused by the
+// next open, so "is fd N still the pipe" is asked of the file, not the number.
+func fdIdentity(t *testing.T, fd int) (dev, ino uint64, open bool) {
+ t.Helper()
+ var st syscall.Stat_t
+ if err := syscall.Fstat(fd, &st); err != nil {
+ return 0, 0, false
+ }
+ return uint64(st.Dev), uint64(st.Ino), true //nolint:unconvert // Dev's width differs by platform
+}
+
+// The token never exists where anything else can read it: not at a path, not
+// in argv, not in the server's environment, and not on the descriptor it came
+// in on once startup is over.
+func TestMCPCommandTokenLeavesNoTrace(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
+ fd := tokenPipe(t, grant.Token+"\n")
+ pipeDev, pipeIno, _ := fdIdentity(t, fd)
+ args := []string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd)}
+
+ session := runMCPCommandWithApp(t, app, args...)
+ assert.Contains(t, toolNames(t, session), "basecamp_connect", "the token came through the descriptor")
+
+ for _, arg := range args {
+ assert.NotContains(t, arg, grant.Token, "argv")
+ }
+ for _, kv := range os.Environ() {
+ assert.NotContains(t, kv, grant.Token, "the server's environment")
+ }
+ if dev, ino, open := fdIdentity(t, fd); open {
+ assert.False(t, dev == pipeDev && ino == pipeIno, "the descriptor the token came in on is closed once it is read")
+ }
+ stateHome := os.Getenv("XDG_STATE_HOME")
+ require.NoError(t, filepath.WalkDir(stateHome, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !d.Type().IsRegular() {
+ return err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ assert.False(t, bytes.Contains(data, []byte(grant.Token)), "no file holds the token: %s", path)
+ return nil
+ }))
+}
+
+// The environment is not a way in: a token left there is refused, and taken
+// out, so no one is led to hand it over that way.
+func TestMCPCommandRefusesATokenInTheEnvironment(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ t.Setenv("BASECAMP_CONNECT_TASK_TOKEN", grant.Token)
+ fd := tokenPipe(t, grant.Token)
+
+ err := executeMCPCommand(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--connect-token-fd")
+ assert.Empty(t, os.Getenv("BASECAMP_CONNECT_TASK_TOKEN"))
+}
+
+// A descriptor that is not a pipe or a socket is refused and left alone: a
+// file would be the token at a path, and a wrong number could be one the
+// process already uses.
+func TestMCPCommandLeavesADescriptorThatIsNotAPipeAlone(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ path := filepath.Join(t.TempDir(), "token")
+ require.NoError(t, os.WriteFile(path, []byte(grant.Token), 0o600))
+ file, err := os.Open(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = file.Close() })
+
+ err = executeMCPCommand(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(int(file.Fd())))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not a pipe or a socket")
+ assert.True(t, fdOpen(int(file.Fd())), "a descriptor that is not the token's is not closed")
+}
+
+func TestMCPCommandRefusesABadTokenDescriptor(t *testing.T) {
+ app, dir, _, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ for name, tc := range map[string]struct {
+ args []string
+ want string
+ }{
+ "no descriptor": {[]string{"--connect-state", dir}, "--connect-token-fd"},
+ "stdin is the MCP wire": {[]string{"--connect-state", dir, "--connect-token-fd", "0"}, "3 or above"},
+ "stdout": {[]string{"--connect-state", dir, "--connect-token-fd", "1"}, "3 or above"},
+ "not open": {[]string{"--connect-state", dir, "--connect-token-fd", "987"}, "it is not open"},
+ "descriptor alone": {[]string{"--connect-token-fd", "5"}, "--connect-state"},
+ "empty": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, " \n"))}, "empty"},
+ "too long": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, strings.Repeat("x", maxTaskTokenBytes+1)))}, "not a task token"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ err := executeMCPCommand(t, app, tc.args...)
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), tc.want), "%q does not say %q", err.Error(), tc.want)
+ })
+ }
+}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index fb23413c1..b95695799 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -113,15 +113,32 @@ func runMCPCommandWithApp(t *testing.T, app *appctx.App, args ...string) *mcp.Cl
done := make(chan error, 1)
go func() { done <- executeMCPCommand(t, app, args...) }()
- t.Cleanup(func() {
- require.NoError(t, <-done, "basecamp mcp exited with error")
- })
- client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil)
- session, err := client.Connect(context.Background(), clientTransport, nil)
- require.NoError(t, err, "MCP initialize failed")
- t.Cleanup(func() { _ = session.Close() })
- return session
+ // Raced against the command: one that refuses to start exits without
+ // serving, and a client connect waiting on it would never return.
+ type connected struct {
+ session *mcp.ClientSession
+ err error
+ }
+ connecting := make(chan connected, 1)
+ go func() {
+ client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil)
+ session, err := client.Connect(context.Background(), clientTransport, nil)
+ connecting <- connected{session, err}
+ }()
+ select {
+ case err := <-done:
+ require.NoError(t, err, "basecamp mcp refused to start")
+ t.Fatal("basecamp mcp exited before serving")
+ return nil
+ case c := <-connecting:
+ require.NoError(t, c.err, "MCP initialize failed")
+ t.Cleanup(func() {
+ require.NoError(t, <-done, "basecamp mcp exited with error")
+ })
+ t.Cleanup(func() { _ = c.session.Close() })
+ return c.session
+ }
}
func TestMCPCommandServesMCP(t *testing.T) {
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
new file mode 100644
index 000000000..3a8c821a2
--- /dev/null
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -0,0 +1,336 @@
+package connector
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/admission"
+)
+
+// TestDispatchLifecycleTable tries every transition of the dispatch lifecycle
+// written at the top of ledger_dispatch.go — every pair, allowed and not —
+// against the ledger, and checks the forbidden ones are refused. The tables
+// here are that comment's, stated again independently of the code's own
+// lifecycle map, so a change to either shows up as a disagreement.
+func TestDispatchLifecycleTable(t *testing.T) {
+ t.Run("record", testRecordTransitions)
+ t.Run("delivery", testDeliveryTransitions)
+ t.Run("guard", testGuardTransitions)
+ t.Run("worker actions", testWorkerActions)
+ t.Run("task", testTaskTransitions)
+}
+
+var allRecordStates = []RecordState{StateSeen, StateAdmitted, StateQueued, StateBlocked, StateDispatched, StateCompleted, StateDiscarded}
+
+// recordTable is the record table: from → the states a move may reach, the
+// state itself (a repeat) excluded. heldRecordTable is dispatched when a
+// worker was handed the event.
+var (
+ recordTable = map[RecordState][]RecordState{
+ StateSeen: {StateAdmitted, StateQueued, StateBlocked, StateDiscarded},
+ StateAdmitted: {StateQueued, StateDispatched, StateBlocked, StateDiscarded},
+ StateQueued: {StateDispatched, StateBlocked, StateDiscarded},
+ StateBlocked: {StateAdmitted, StateQueued, StateDispatched, StateDiscarded},
+ StateDispatched: {StateCompleted, StateBlocked, StateAdmitted},
+ StateCompleted: nil,
+ StateDiscarded: nil,
+ }
+ heldRecordTable = []RecordState{StateCompleted}
+)
+
+// reachRecord puts event 1 in state, handed to a worker when held.
+func reachRecord(t *testing.T, ledger *Ledger, state RecordState, held bool) {
+ t.Helper()
+ ctx := context.Background()
+ commit := func(v admission.Verdict) {
+ t.Helper()
+ _, err := ledger.Admission().Commit(ctx, v)
+ require.NoError(t, err)
+ }
+ seenRecord(t, ledger, 1)
+ switch state {
+ case StateSeen:
+ case StateAdmitted:
+ commit(admittedVerdict(1, 0, "recording:1"))
+ case StateQueued:
+ seenRecord(t, ledger, 9)
+ commit(admittedVerdict(9, 0, "recording:1"))
+ commit(admittedVerdict(1, 0, "recording:1"))
+ case StateBlocked:
+ commit(blockedVerdict(1, 0, admission.ReasonReadFailed))
+ case StateDiscarded:
+ v := blockedVerdict(1, 0, admission.ReasonStale)
+ v.State = admission.StateDiscarded
+ commit(v)
+ case StateDispatched, StateCompleted:
+ commit(admittedVerdict(1, 0, "recording:1"))
+ grant, err := ledger.CreateTask(ctx, []int64{1})
+ require.NoError(t, err)
+ if held {
+ d, err := ledger.Dispatch(ctx, grant.Token, adapterAgentID)
+ require.NoError(t, err)
+ _, _, err = d.Get(ctx, 1)
+ require.NoError(t, err)
+ }
+ if state == StateCompleted {
+ require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, ""))
+ }
+ }
+ require.Equal(t, state, getRecord(t, ledger, 1).State)
+}
+
+func reasonFor(state RecordState) string {
+ if state == StateBlocked || state == StateDiscarded {
+ return "a_reason"
+ }
+ return ""
+}
+
+func testRecordTransitions(t *testing.T) {
+ for _, held := range []bool{false, true} {
+ for _, from := range allRecordStates {
+ if held && from != StateDispatched {
+ continue
+ }
+ allowed := recordTable[from]
+ if held {
+ allowed = heldRecordTable
+ }
+ for _, to := range allRecordStates {
+ want := to == from || slices.Contains(allowed, to)
+ t.Run(fmt.Sprintf("%s to %s, held %v", from, to, held), func(t *testing.T) {
+ ledger := newTestLedger(t)
+ reachRecord(t, ledger, from, held)
+
+ err := ledger.SetState(context.Background(), 1, to, reasonFor(to))
+
+ if want {
+ require.NoError(t, err)
+ assert.Equal(t, to, getRecord(t, ledger, 1).State)
+ return
+ }
+ require.Error(t, err)
+ assert.Equal(t, from, getRecord(t, ledger, 1).State, "a refused move moves nothing")
+ if held {
+ assert.ErrorIs(t, err, ErrHeldByWorker)
+ } else {
+ assert.ErrorIs(t, err, ErrNotATransition)
+ }
+ })
+ }
+ }
+ }
+}
+
+var deliveries = []Delivery{DeliveryAdmitted, DeliveryExposed, DeliveryDelivered, DeliveryCompleted}
+
+// deliveryTable: from → the deliveries a row may move to, repeats excluded.
+var deliveryTable = map[Delivery][]Delivery{
+ DeliveryAdmitted: {DeliveryExposed},
+ DeliveryExposed: {DeliveryDelivered, DeliveryCompleted},
+ DeliveryDelivered: {DeliveryCompleted},
+ DeliveryCompleted: nil,
+}
+
+// The delivery and guard tables are enforced by the database itself, so they
+// are tried with raw writes: whatever writes to the file meets them.
+func testDeliveryTransitions(t *testing.T) {
+ for _, from := range deliveries {
+ for _, to := range deliveries {
+ want := to == from || slices.Contains(deliveryTable[from], to)
+ t.Run(fmt.Sprintf("%s to %s", from, to), func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `DROP TRIGGER task_events_delivery_moves_forward`)
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `DROP TRIGGER task_events_exposure_comes_first`)
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(from))
+ require.NoError(t, err)
+ reopened := f.ledger.restoreTriggers(t)
+
+ _, err = reopened.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(to))
+
+ if want {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ assert.Equal(t, string(from), f.row(t, 1).Delivery)
+ }
+ })
+ }
+ }
+}
+
+var guards = []string{"", "armed", "canceled", "fired"}
+
+var guardTable = map[string][]string{
+ "": nil,
+ "armed": {"canceled", "fired"},
+ "canceled": nil,
+ "fired": nil,
+}
+
+func testGuardTransitions(t *testing.T) {
+ for _, from := range guards {
+ for _, to := range guards {
+ want := to == from || slices.Contains(guardTable[from], to)
+ t.Run(fmt.Sprintf("%q to %q", from, to), func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `DROP TRIGGER task_events_guard_settles_once`)
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET guard = ? WHERE event_id = 1`, from)
+ require.NoError(t, err)
+ reopened := f.ledger.restoreTriggers(t)
+
+ _, err = reopened.db.ExecContext(ctx, `UPDATE task_events SET guard = ? WHERE event_id = 1`, to)
+
+ if want {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ assert.Equal(t, from, f.row(t, 1).Guard)
+ }
+ })
+ }
+ }
+}
+
+// restoreTriggers puts back the triggers a test dropped to stage a row, by
+// re-running migration 5's trigger statements.
+func (l *Ledger) restoreTriggers(t *testing.T) *Ledger {
+ t.Helper()
+ ctx := context.Background()
+ for _, name := range []string{"task_events_delivery_moves_forward", "task_events_exposure_comes_first", "task_events_guard_settles_once"} {
+ var exists int
+ require.NoError(t, l.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?`, name).Scan(&exists))
+ if exists == 1 {
+ continue
+ }
+ statement := triggerStatement(t, name)
+ _, err := l.db.ExecContext(ctx, statement)
+ require.NoError(t, err)
+ }
+ return l
+}
+
+// triggerStatement is a trigger's CREATE statement as the migrations declare it.
+func triggerStatement(t *testing.T, name string) string {
+ t.Helper()
+ for _, migration := range migrations {
+ start := strings.Index(migration, "CREATE TRIGGER "+name)
+ if start < 0 {
+ continue
+ }
+ end := strings.Index(migration[start:], "END;")
+ require.GreaterOrEqual(t, end, 0)
+ return migration[start : start+end+len("END;")]
+ }
+ t.Fatalf("no migration declares trigger %s", name)
+ return ""
+}
+
+// testWorkerActions is the worker's side: each action against each delivery
+// state, on a live task and on a superseded one.
+func testWorkerActions(t *testing.T) {
+ type outcome struct {
+ err error
+ delivery Delivery
+ }
+ type action struct {
+ name string
+ do func(*TaskDispatch) error
+ }
+ actions := []action{
+ {"get", func(d *TaskDispatch) error { _, _, err := d.Get(context.Background(), 1); return err }},
+ {"ack", func(d *TaskDispatch) error { _, err := d.Ack(context.Background(), 1, nil); return err }},
+ {"complete", func(d *TaskDispatch) error {
+ _, err := d.Complete(context.Background(), 1, Completion{Outcome: OutcomeSucceeded})
+ return err
+ }},
+ }
+ live := map[string]map[Delivery]outcome{
+ "get": {
+ DeliveryAdmitted: {nil, DeliveryExposed}, DeliveryExposed: {nil, DeliveryExposed},
+ DeliveryDelivered: {nil, DeliveryDelivered}, DeliveryCompleted: {nil, DeliveryCompleted},
+ },
+ "ack": {
+ DeliveryAdmitted: {ErrNotExposed, DeliveryAdmitted}, DeliveryExposed: {nil, DeliveryDelivered},
+ DeliveryDelivered: {nil, DeliveryDelivered}, DeliveryCompleted: {nil, DeliveryCompleted},
+ },
+ "complete": {
+ DeliveryAdmitted: {ErrNotExposed, DeliveryAdmitted}, DeliveryExposed: {nil, DeliveryCompleted},
+ DeliveryDelivered: {nil, DeliveryCompleted}, DeliveryCompleted: {nil, DeliveryCompleted},
+ },
+ }
+ reach := func(t *testing.T, f dispatchFixture, delivery Delivery) {
+ t.Helper()
+ ctx := context.Background()
+ if delivery == DeliveryAdmitted {
+ return
+ }
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ switch delivery {
+ case DeliveryAdmitted, DeliveryExposed:
+ case DeliveryDelivered:
+ _, err = f.d.Ack(ctx, 1, nil)
+ case DeliveryCompleted:
+ _, err = f.d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ }
+ require.NoError(t, err)
+ }
+ for _, superseded := range []bool{false, true} {
+ for _, a := range actions {
+ for _, delivery := range deliveries {
+ t.Run(fmt.Sprintf("%s at %s, superseded %v", a.name, delivery, superseded), func(t *testing.T) {
+ f := newDispatchFixture(t)
+ reach(t, f, delivery)
+ if superseded {
+ require.NoError(t, f.ledger.SupersedeTask(context.Background(), f.grant.ID))
+ }
+
+ err := a.do(f.d)
+
+ want := live[a.name][delivery]
+ if superseded {
+ want = outcome{ErrTaskTokenRefused, delivery}
+ }
+ if want.err == nil {
+ require.NoError(t, err)
+ } else {
+ require.ErrorIs(t, err, want.err)
+ }
+ assert.Equal(t, string(want.delivery), f.row(t, 1).Delivery)
+ })
+ }
+ }
+ }
+}
+
+// testTaskTransitions: live to superseded, once, and a token valid only
+// while its task is live.
+func testTaskTransitions(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.Dispatch(ctx, f.grant.Token, adapterAgentID)
+ require.NoError(t, err, "live: the token binds")
+
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err = f.ledger.Dispatch(ctx, f.grant.Token, adapterAgentID)
+ require.ErrorIs(t, err, ErrTaskTokenRefused, "superseded: the token is refused")
+
+ var first string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT superseded_at FROM tasks WHERE id = ?`, f.grant.ID).Scan(&first))
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID), "superseded is terminal, and a repeat is harmless")
+ var again string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT superseded_at FROM tasks WHERE id = ?`, f.grant.ID).Scan(&again))
+ assert.Equal(t, first, again)
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index cfea8ab13..2f34ee36e 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -392,7 +392,10 @@ CREATE INDEX events_conversation ON events (conversation_key, state);
// delivery is admitted → exposed → delivered → completed and never goes
// back, held by the trigger as the events lifecycle is. guard is the
// thirty-second acknowledgement guard: '' where none applies, armed until
- // get_dispatch cancels it or the connector fires it.
+ // get_dispatch cancels it or the connector fires it, and settled once.
+ // A record a worker was handed leaves dispatched only to completed. The
+ // whole lifecycle these enforce is written down at the top of
+ // ledger_dispatch.go.
//
// An event is on at most one live task. retired_at is set on every row of
// a task when it is superseded, and the unique index over the rows not
@@ -427,6 +430,21 @@ CREATE TABLE task_events (
CREATE UNIQUE INDEX task_events_one_live_task ON task_events (event_id) WHERE retired_at IS NULL;
+CREATE TRIGGER events_handed_work_settles_first
+BEFORE UPDATE OF state ON events
+WHEN OLD.state = 'dispatched' AND NEW.state NOT IN ('dispatched', 'completed')
+ AND EXISTS (SELECT 1 FROM task_events WHERE event_id = OLD.id AND delivery IN ('exposed', 'delivered'))
+BEGIN
+ SELECT RAISE(ABORT, 'a worker was handed this event; it leaves dispatched only when completed');
+END;
+
+CREATE TRIGGER task_events_guard_settles_once
+BEFORE UPDATE OF guard ON task_events
+WHEN NEW.guard <> OLD.guard AND NOT (OLD.guard = 'armed' AND NEW.guard IN ('canceled', 'fired'))
+BEGIN
+ SELECT RAISE(ABORT, 'a guard only goes from armed to canceled or fired');
+END;
+
CREATE TRIGGER task_events_delivery_moves_forward
BEFORE UPDATE OF delivery ON task_events
WHEN (CASE NEW.delivery WHEN 'admitted' THEN 0 WHEN 'exposed' THEN 1 WHEN 'delivered' THEN 2 ELSE 3 END)
@@ -434,6 +452,13 @@ WHEN (CASE NEW.delivery WHEN 'admitted' THEN 0 WHEN 'exposed' THEN 1 WHEN 'deliv
BEGIN
SELECT RAISE(ABORT, 'a delivery state never goes back');
END;
+
+CREATE TRIGGER task_events_exposure_comes_first
+BEFORE UPDATE OF delivery ON task_events
+WHEN OLD.delivery = 'admitted' AND NEW.delivery IN ('delivered', 'completed')
+BEGIN
+ SELECT RAISE(ABORT, 'nothing a worker was never handed is acknowledged or completed');
+END;
`,
}
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 5c41db2b3..10d295c1a 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -25,6 +25,89 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)
+// The dispatch lifecycle, as one state machine.
+//
+// Three things have state here, and a fourth is the guard on one of them.
+// This comment is the contract; lifecycle (ledger_events.go), move, the
+// schema's triggers and index, and the functions below enforce exactly it,
+// and TestDispatchLifecycleTable tries every transition against it.
+//
+// # Record (events.state)
+//
+// from to who rule
+// seen admitted admission verdict, conversation not live
+// seen queued admission verdict, conversation live
+// seen blocked admission verdict
+// seen discarded admission verdict
+// blocked admitted admission re-decided
+// blocked queued admission re-decided, conversation live
+// blocked blocked admission re-decided, still blocked
+// blocked discarded admission, operator verdict, or discard
+// blocked dispatched lifecycle bookkeeping —
+// admitted dispatched dispatcher (CreateTask) joins a task
+// queued dispatched dispatcher (CreateTask) joins a task
+// dispatched dispatched dispatcher (CreateTask) redispatch onto a new task
+// dispatched admitted dispatcher (SupersedeTask, withdrawal) never handed to a worker
+// dispatched blocked dispatcher never handed to a worker
+// dispatched completed worker (complete_dispatch), dispatcher the outcome, reported or settled
+// admitted queued lifecycle bookkeeping —
+// admitted blocked lifecycle bookkeeping —
+// admitted discarded operator discard
+// queued blocked lifecycle bookkeeping —
+// queued discarded operator discard
+// completed — nobody terminal
+// discarded — nobody terminal
+//
+// Writing the state a record already has is a repeat and always allowed. Any
+// pair not in the table is refused.
+//
+// # Task (tasks)
+//
+// live created by the dispatcher (CreateTask); its token is valid
+// superseded by the dispatcher or an operator's redispatch (SupersedeTask);
+// its token is refused; terminal
+//
+// # Delivery (task_events.delivery), per event on a task
+//
+// admitted → exposed worker (get_dispatch), dispatcher at launch
+// exposed → delivered worker (ack_dispatch)
+// exposed → completed worker (complete_dispatch)
+// delivered → completed worker (complete_dispatch), dispatcher settlement
+//
+// Forward only, and never skipping exposure: nothing a worker was never
+// handed is acknowledged or completed. A row is retired (retired_at)
+// when its task is superseded; a worker can touch only its live task's rows.
+//
+// # Guard (task_events.guard)
+//
+// armed → canceled worker (get_dispatch)
+// armed → fired connector's thirty-second acknowledgement
+//
+// '' (none) and armed are only ever written when the row is created.
+//
+// # Invariants
+//
+// 1. One live task per event: task_events_one_live_task.
+// 2. One task per conversation: an event joins a task only if every
+// dispatched record on its conversation joins the same task (createTask).
+// 3. The token is valid only while its task is live, checked inside every
+// worker call's own transaction.
+// 4. Nothing leaves dispatched while a worker may still act: a record with a
+// delivery exposed or delivered, on any task, leaves dispatched only to
+// completed (move, and the events_handed_work_settles_first trigger).
+// 5. A worker acts only on its own task's rows, reports only what it was
+// handed, and a reported outcome stands.
+// 6. A task is made only of instructions a worker can pull, and finished
+// work is never handed out for the first time.
+// 7. Superseding retires the task's rows and returns only what it never
+// exposed to admitted; what a worker was handed stays dispatched (4) and
+// waits for its outcome or a redispatch, which supersedes and creates in
+// one transaction (supersedeTask and createTask take the caller's).
+// 8. Completed and discarded are terminal (events_terminal_is_terminal).
+//
+// Every transaction takes the write lock as it opens (_txlock=immediate), and
+// each call ends it as soon as its ledger work is done.
+
// Delivery is an event's delivery state on a task. It moves forward only.
type Delivery string
@@ -247,6 +330,12 @@ func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) er
}
unexposed = append(unexposed, id)
}
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ // Retiring the task on a partial list would leave the rest dispatched
+ // on no live task, never exposed and never returned.
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
if err := rows.Close(); err != nil {
return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
}
@@ -637,6 +726,12 @@ func (d *TaskDispatch) report(ctx context.Context, eventID int64, apply func(con
if err := tx.Commit(); err != nil {
return Receipt{}, fmt.Errorf("connector: commit report on %d: %w", eventID, err)
}
+ } else if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
+ return Receipt{}, fmt.Errorf("connector: end report on %d: %w", eventID, err)
+ }
+ // The ledger is free before the receipt is built, as in get.
+ if d.afterTx != nil {
+ d.afterTx()
}
receipt := Receipt{EventID: eventID, Delivery: te.delivery, Outcome: Outcome(te.outcome)}
if te.ackID.Valid {
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 4446f41a5..292b8b600 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -617,14 +617,15 @@ func TestTheEarliestSkipsAnEventThatLeftThePath(t *testing.T) {
assert.Equal(t, int64(2), got.EventID)
}
-// A worker's report is what it did: it is recorded even when the record has
-// moved since, and only a dispatched record is completed by it.
-func TestAReportIsRecordedWhateverHappenedToTheRecord(t *testing.T) {
+// A worker's report is what it did: a record the dispatcher settled as
+// completed while the worker was still going keeps its settlement, and the
+// report the worker then sends is recorded against it.
+func TestAReportIsRecordedAfterSettlement(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
_, _, err := f.d.Get(ctx, 2)
require.NoError(t, err)
- require.NoError(t, f.ledger.SetState(ctx, 2, StateAdmitted, ""))
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateCompleted, ""))
_, err = f.d.Ack(ctx, 2, nil)
require.NoError(t, err)
@@ -632,7 +633,36 @@ func TestAReportIsRecordedWhateverHappenedToTheRecord(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, DeliveryCompleted, receipt.Delivery)
assert.Equal(t, OutcomeFailed, receipt.Outcome)
- assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 2).State)
+ assert.Equal(t, StateCompleted, getRecord(t, f.ledger, 2).State)
+}
+
+// Invariant 4: nothing leaves dispatched while a worker may still act on it.
+// A handed record cannot be withdrawn, blocked or requeued — not through the
+// ledger's write, and not around it — so its conversation stays busy and no
+// sibling starts a second task until its outcome is in.
+func TestAHandedRecordLeavesDispatchedOnlyWhenCompleted(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID), "superseding does not release what a worker holds")
+
+ for _, to := range []struct {
+ state RecordState
+ reason string
+ }{{StateAdmitted, ""}, {StateBlocked, "read_failed"}} {
+ err := f.ledger.SetState(ctx, 1, to.state, to.reason)
+ require.ErrorIs(t, err, ErrHeldByWorker, "to %s", to.state)
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE events SET state = ? WHERE id = 1`, string(to.state))
+ require.Error(t, err, "the database refuses it too")
+ }
+ assert.Equal(t, StateDispatched, getRecord(t, f.ledger, 1).State)
+ _, err = f.ledger.CreateTask(ctx, []int64{2})
+ require.ErrorIs(t, err, ErrConversationBusy, "the sibling waits for the handed event")
+
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+ _, err = f.ledger.CreateTask(ctx, []int64{2})
+ require.NoError(t, err)
}
// A worker's open never leaves a ledger behind where there was none: not
@@ -873,3 +903,32 @@ func TestGetDispatchDoesNotHoldTheLedgerWhileItBuildsItsAnswer(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 2, writes)
}
+
+// Acknowledgements and completions free the ledger before building their
+// receipt too — on the retry that writes nothing as much as on the first.
+func TestReportsDoNotHoldTheLedgerWhileTheyBuildTheirReceipt(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ other, err := OpenExistingLedger(ctx, f.path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = other.Close() })
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err)
+
+ writes := 0
+ f.d.afterTx = func() {
+ writes++
+ fresh, err := other.RecordSeen(ctx, testEvent(int64(200+writes)), LanePoll)
+ require.NoError(t, err)
+ require.True(t, fresh)
+ }
+ for range 2 {
+ _, err = f.d.Ack(ctx, 1, nil)
+ require.NoError(t, err)
+ }
+ for range 2 {
+ _, err = f.d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ require.NoError(t, err)
+ }
+ assert.Equal(t, 4, writes)
+}
diff --git a/internal/connector/ledger_events.go b/internal/connector/ledger_events.go
index 54d6f3281..18233f294 100644
--- a/internal/connector/ledger_events.go
+++ b/internal/connector/ledger_events.go
@@ -367,6 +367,11 @@ func (l *Ledger) move(ctx context.Context, db dbtx, t transition) (bool, error)
args = append(args, *t.revision)
}
query.WriteString(" AND state IN (" + strings.TrimSuffix(strings.Repeat("?, ", len(froms)), ", ") + ")")
+ if t.state != StateDispatched && t.state != StateCompleted {
+ // Invariant 4 of the dispatch lifecycle (ledger_dispatch.go): a
+ // record a worker was handed leaves dispatched only to completed.
+ query.WriteString(" AND NOT (" + heldByWorker + ")")
+ }
for _, from := range froms {
args = append(args, from)
}
@@ -385,6 +390,15 @@ func (l *Ledger) move(ctx context.Context, db dbtx, t transition) (bool, error)
return affected > 0, nil
}
+// heldByWorker is true of a dispatched events row a worker was handed and
+// has not reported on: a delivery exposed or delivered, on any task.
+const heldByWorker = `state = 'dispatched' AND EXISTS (
+ SELECT 1 FROM task_events WHERE task_events.event_id = events.id AND delivery IN ('exposed', 'delivered'))`
+
+// ErrHeldByWorker is a move out of dispatched for an event a worker was
+// handed and has not reported on. Only its outcome moves it.
+var ErrHeldByWorker = errors.New("a worker was handed this event; it leaves dispatched only when completed")
+
// explainRefusal says why an update changed nothing: there is no such record,
// or the record is somewhere the lifecycle cannot leave for state.
//
@@ -399,6 +413,12 @@ func (l *Ledger) explainRefusal(ctx context.Context, id int64, state RecordState
case err != nil:
return fmt.Errorf("connector: set state of %d: %w", id, err)
}
+ if RecordState(current) == StateDispatched && state != StateDispatched && state != StateCompleted {
+ var held bool
+ if err := l.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM events WHERE id = ? AND `+heldByWorker+`)`, id).Scan(&held); err == nil && held {
+ return fmt.Errorf("connector: set state of %d: %w", id, ErrHeldByWorker)
+ }
+ }
return fmt.Errorf("connector: set state of %d: %s to %s is %w", id, current, state, ErrNotATransition)
}
From d91e3616e54d63aa274e5951074fdc415b2fa1a7 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 11:44:45 +0200
Subject: [PATCH 12/33] Withdraw a failed spawn's exposure; bound the token
read; refuse an env token
The lifecycle forbade the spec's automatic retry: an originating event is
exposed at launch, and once exposed a record could leave dispatched only to
completed, so a spawn proven to have failed before any worker existed could
be neither retried nor blocked. task_events.withdrawn_at marks that case,
settable only on an exposure of a superseded task and only once, and a
withdrawn exposure no longer holds its record: withdrawExposure returns it to
admitted for its retry, or to blocked after a second failure. The table,
trigger and held predicate say so, and a plain index serves the lookup.
The token read stops at the first newline and has a deadline, so a write end
left open elsewhere cannot hang startup. A token in the environment is taken
out and refused by any server, and --connect-token-fd alone is refused
whenever it is given without --connect-state. basecamp mcp's help documents
the descriptor as the only way a connect token arrives.
---
internal/commands/mcp.go | 81 +++++-------------
.../commands/mcp_connect_token_unix_test.go | 61 ++++++++++++--
internal/commands/mcp_token_other.go | 11 +++
internal/commands/mcp_token_unix.go | 84 +++++++++++++++++++
internal/connector/dispatch_lifecycle_test.go | 47 +++++++++++
internal/connector/ledger.go | 21 ++++-
internal/connector/ledger_dispatch.go | 45 +++++++++-
internal/connector/ledger_dispatch_test.go | 42 ++++++++++
internal/connector/ledger_events.go | 12 ++-
9 files changed, 331 insertions(+), 73 deletions(-)
create mode 100644 internal/commands/mcp_token_other.go
create mode 100644 internal/commands/mcp_token_unix.go
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 0bc506150..fcb29db69 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -4,13 +4,12 @@ import (
"context"
"errors"
"fmt"
- "io"
"log/slog"
"os"
"os/signal"
"path/filepath"
- "strings"
"syscall"
+ "time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
@@ -34,6 +33,10 @@ const connectTaskTokenEnv = "BASECAMP_CONNECT_TASK_TOKEN"
// 43 characters; anything near this is not one.
const maxTaskTokenBytes = 4096
+// taskTokenReadTimeout bounds the wait for the token. A write end left open
+// somewhere — leaked into another process — must not hang startup silently.
+var taskTokenReadTimeout = 5 * time.Second
+
// NewMCPCmd creates the mcp command serving Basecamp over MCP on stdio.
func NewMCPCmd() *cobra.Command {
var readOnly bool
@@ -48,10 +51,17 @@ func NewMCPCmd() *cobra.Command {
"projects, todos, cards, messages, and more as tools backed by your signed-in\n" +
"account.\n\n" +
"Register it with an MCP client as a stdio server, e.g.:\n\n" +
- " claude mcp add basecamp -- basecamp mcp",
+ " claude mcp add basecamp -- basecamp mcp\n\n" +
+ "A worker started by the agent connector also gets the basecamp_connect domain,\n" +
+ "for its one task: --connect-state names the connector's state directory, and\n" +
+ "the task token arrives on an inherited pipe or socket named by\n" +
+ "--connect-token-fd, which is read and closed at startup. That descriptor is the\n" +
+ "only way in: the token is never taken from a flag value, a file or the\n" +
+ "environment, and a token found in $BASECAMP_CONNECT_TASK_TOKEN is refused.",
Example: ` basecamp mcp
basecamp mcp --read-only
- basecamp mcp --domains projects,todos,cards`,
+ basecamp mcp --domains projects,todos,cards
+ basecamp mcp --connect-state ~/.local/state/basecamp/connect/- --connect-token-fd 3`,
Args: cobra.NoArgs,
Annotations: map[string]string{
"agent_notes": "Long-running server; stdout speaks the MCP wire protocol. Not for interactive use.",
@@ -67,8 +77,15 @@ func NewMCPCmd() *cobra.Command {
// anything else runs: authentication can start helper processes,
// and a child started then would inherit an open descriptor.
var taskToken string
+ // A token in the environment is taken out and refused whatever
+ // the flags: it is not a way in for any server.
+ if _, set := os.LookupEnv(connectTaskTokenEnv); set {
+ _ = os.Unsetenv(connectTaskTokenEnv)
+ return output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
+ "Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
+ }
switch {
- case connectState == "" && connectTokenFD >= 0:
+ case connectState == "" && cmd.Flags().Changed("connect-token-fd"):
return output.ErrUsage("--connect-token-fd is only for a server started with --connect-state")
case connectState != "":
if readOnly {
@@ -76,11 +93,6 @@ func NewMCPCmd() *cobra.Command {
// the token or the ledger is touched.
return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
}
- if _, set := os.LookupEnv(connectTaskTokenEnv); set {
- _ = os.Unsetenv(connectTaskTokenEnv)
- return output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
- "Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
- }
token, err := readTaskToken(connectTokenFD)
if err != nil {
return err
@@ -154,55 +166,8 @@ func stateDirHint(refusal *connector.StateDirError) string {
}
}
-// readTaskToken reads the task token from an inherited descriptor and closes
-// it. The connector hands the token over as the read end of a pipe, so it never
-// exists at a path, in argv or in the environment; once read, the descriptor
-// is gone too, and nothing this process starts can inherit it.
-//
-// Descriptors 0 to 2 are refused: stdin and stdout are the MCP wire and stderr
-// is the log.
-func readTaskToken(fd int) (string, error) {
- switch {
- case fd < 0:
- return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
- case fd < 3:
- return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be 3 or above", fd))
- }
- file := os.NewFile(uintptr(fd), "connect-token")
- if file == nil {
- return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is not a descriptor", fd))
- }
- // Only a pipe or a socket is taken, and anything else is left exactly as
- // it was — not read, not closed. A regular file would be the token at a
- // path, and a wrong number could name a descriptor this process already
- // uses for something else.
- info, err := file.Stat()
- if err != nil {
- return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: it is not open", fd))
- }
- if info.Mode()&(os.ModeNamedPipe|os.ModeSocket) == 0 {
- return "", output.ErrUsage(fmt.Sprintf("descriptor %d is not a pipe or a socket; the task token is handed over on one, never from a file", fd))
- }
- data, readErr := io.ReadAll(io.LimitReader(file, maxTaskTokenBytes+1))
- closeErr := file.Close()
- if readErr != nil {
- return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, readErr))
- }
- if closeErr != nil {
- return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, closeErr))
- }
- if len(data) > maxTaskTokenBytes {
- return "", output.ErrUsage(fmt.Sprintf("descriptor %d carries more than %d bytes; that is not a task token", fd, maxTaskTokenBytes))
- }
- token := strings.TrimSpace(string(data))
- if token == "" {
- return "", output.ErrUsage(fmt.Sprintf("descriptor %d carried an empty task token", fd))
- }
- return token, nil
-}
-
// openConnectDispatch opens the connector's ledger in stateDir and binds it to
-// the task token in the environment.
+// the task token read from the inherited descriptor (readTaskToken).
//
// The directory is the connector's own, named "-",
// and it must belong to the account this server serves: that is where the
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 3d022b357..68b78ce56 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -11,6 +11,7 @@ import (
"strings"
"syscall"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -115,19 +116,32 @@ func TestMCPCommandLeavesADescriptorThatIsNotAPipeAlone(t *testing.T) {
assert.True(t, fdOpen(int(file.Fd())), "a descriptor that is not the token's is not closed")
}
+// Not only in connect mode: any server started with a token in the
+// environment takes it out and refuses to start.
+func TestMCPCommandRefusesATokenInTheEnvironmentWithoutConnectState(t *testing.T) {
+ app, _, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
+ t.Setenv("BASECAMP_CONNECT_TASK_TOKEN", grant.Token)
+
+ err := executeMCPCommand(t, app)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "BASECAMP_CONNECT_TASK_TOKEN")
+ assert.Empty(t, os.Getenv("BASECAMP_CONNECT_TASK_TOKEN"))
+}
+
func TestMCPCommandRefusesABadTokenDescriptor(t *testing.T) {
app, dir, _, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
for name, tc := range map[string]struct {
args []string
want string
}{
- "no descriptor": {[]string{"--connect-state", dir}, "--connect-token-fd"},
- "stdin is the MCP wire": {[]string{"--connect-state", dir, "--connect-token-fd", "0"}, "3 or above"},
- "stdout": {[]string{"--connect-state", dir, "--connect-token-fd", "1"}, "3 or above"},
- "not open": {[]string{"--connect-state", dir, "--connect-token-fd", "987"}, "it is not open"},
- "descriptor alone": {[]string{"--connect-token-fd", "5"}, "--connect-state"},
- "empty": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, " \n"))}, "empty"},
- "too long": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, strings.Repeat("x", maxTaskTokenBytes+1)))}, "not a task token"},
+ "no descriptor": {[]string{"--connect-state", dir}, "--connect-token-fd"},
+ "stdin is the MCP wire": {[]string{"--connect-state", dir, "--connect-token-fd", "0"}, "3 or above"},
+ "stdout": {[]string{"--connect-state", dir, "--connect-token-fd", "1"}, "3 or above"},
+ "not open": {[]string{"--connect-state", dir, "--connect-token-fd", "987"}, "it is not open"},
+ "descriptor alone": {[]string{"--connect-token-fd", "5"}, "--connect-state"},
+ "a negative descriptor alone": {[]string{"--connect-token-fd", "-1"}, "--connect-state"},
+ "empty": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, " \n"))}, "empty"},
+ "too long": {[]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(tokenPipe(t, strings.Repeat("x", maxTaskTokenBytes+1)))}, "not a task token"},
} {
t.Run(name, func(t *testing.T) {
err := executeMCPCommand(t, app, tc.args...)
@@ -136,3 +150,36 @@ func TestMCPCommandRefusesABadTokenDescriptor(t *testing.T) {
})
}
}
+
+// heldPipe is a token pipe whose write end the test keeps open, as a write
+// end leaked into some other process would be.
+func heldPipe(t *testing.T, written string) int {
+ t.Helper()
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = w.Close() })
+ _, err = w.WriteString(written)
+ require.NoError(t, err)
+ fd, err := syscall.Dup(int(r.Fd()))
+ require.NoError(t, err)
+ require.NoError(t, r.Close())
+ return fd
+}
+
+// A write end left open somewhere does not hang startup: the token ends at its
+// newline, and a token that never arrives is a refusal within the timeout.
+func TestMCPCommandDoesNotWaitOnAWriteEndLeftOpen(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
+
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(heldPipe(t, grant.Token+"\n")))
+ assert.Contains(t, toolNames(t, session), "basecamp_connect", "the newline ends the token")
+
+ previous := taskTokenReadTimeout
+ taskTokenReadTimeout = 200 * time.Millisecond
+ t.Cleanup(func() { taskTokenReadTimeout = previous })
+ started := time.Now()
+ err := executeMCPCommand(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(heldPipe(t, grant.Token)))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no task token arrived")
+ assert.Less(t, time.Since(started), 5*time.Second)
+}
diff --git a/internal/commands/mcp_token_other.go b/internal/commands/mcp_token_other.go
new file mode 100644
index 000000000..83063bbfc
--- /dev/null
+++ b/internal/commands/mcp_token_other.go
@@ -0,0 +1,11 @@
+//go:build !unix
+
+package commands
+
+import "github.com/basecamp/basecamp-cli/internal/output"
+
+// readTaskToken is refused where the connector cannot run: its ledger's
+// privacy cannot be established off Unix, so no worker is started there.
+func readTaskToken(int) (string, error) {
+ return "", output.ErrUsage("--connect-state is only available on Unix, where the connector runs")
+}
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_unix.go
new file mode 100644
index 000000000..d6f609672
--- /dev/null
+++ b/internal/commands/mcp_token_unix.go
@@ -0,0 +1,84 @@
+//go:build unix
+
+package commands
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/basecamp/basecamp-cli/internal/output"
+)
+
+// readTaskToken reads the task token from an inherited descriptor and closes
+// it. The connector hands the token over as the read end of a pipe, so it never
+// exists at a path, in argv or in the environment; once read, the descriptor
+// is gone too, and nothing this process starts can inherit it.
+//
+// Descriptors 0 to 2 are refused: stdin and stdout are the MCP wire and stderr
+// is the log. Only a pipe or a socket is taken, and anything else is left
+// exactly as it was — not read, not closed: a regular file would be the token
+// at a path, and a wrong number could name a descriptor this process already
+// uses.
+//
+// The read ends at the first newline or at end of file, and is bounded in
+// size and in time, so a write end left open somewhere cannot hang startup.
+func readTaskToken(fd int) (string, error) {
+ switch {
+ case fd < 0:
+ return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
+ case fd < 3:
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be 3 or above", fd))
+ }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: it is not open", fd))
+ }
+ if kind := st.Mode & unix.S_IFMT; kind != unix.S_IFIFO && kind != unix.S_IFSOCK {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d is not a pipe or a socket; the task token is handed over on one, never from a file", fd))
+ }
+ // Non-blocking before it is wrapped, so the runtime polls it and a read
+ // deadline applies.
+ if err := unix.SetNonblock(fd, true); err != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
+ }
+ file := os.NewFile(uintptr(fd), "connect-token")
+ defer file.Close()
+ if err := file.SetReadDeadline(time.Now().Add(taskTokenReadTimeout)); err != nil {
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
+ }
+
+ var data []byte
+ buf := make([]byte, 256)
+ for len(data) <= maxTaskTokenBytes && !bytes.Contains(data, []byte("\n")) {
+ n, err := file.Read(buf)
+ data = append(data, buf[:n]...)
+ if err == nil {
+ continue
+ }
+ if errors.Is(err, os.ErrDeadlineExceeded) {
+ return "", output.ErrUsage(fmt.Sprintf("no task token arrived on descriptor %d within %s", fd, taskTokenReadTimeout))
+ }
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
+ }
+ if i := bytes.IndexByte(data, '\n'); i >= 0 {
+ data = data[:i]
+ }
+ if len(data) > maxTaskTokenBytes {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d carries more than %d bytes; that is not a task token", fd, maxTaskTokenBytes))
+ }
+ token := strings.TrimSpace(string(data))
+ if token == "" {
+ return "", output.ErrUsage(fmt.Sprintf("descriptor %d carried an empty task token", fd))
+ }
+ return token, nil
+}
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index 3a8c821a2..79c9927ad 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -24,6 +24,53 @@ func TestDispatchLifecycleTable(t *testing.T) {
t.Run("guard", testGuardTransitions)
t.Run("worker actions", testWorkerActions)
t.Run("task", testTaskTransitions)
+ t.Run("withdrawal", testWithdrawal)
+}
+
+// testWithdrawal: an exposure is withdrawn only on a superseded task, only
+// while exposed, and only once — and then the record is work again.
+func testWithdrawal(t *testing.T) {
+ for _, superseded := range []bool{false, true} {
+ for _, delivery := range deliveries {
+ want := superseded && delivery == DeliveryExposed
+ t.Run(fmt.Sprintf("at %s, superseded %v", delivery, superseded), func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ // Staged along the allowed steps, so the triggers are left in
+ // place.
+ for _, step := range deliveries[1 : slices.Index(deliveries, delivery)+1] {
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(step))
+ require.NoError(t, err)
+ }
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ if superseded {
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
+ }
+
+ err = f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, "")
+
+ if !want {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit())
+ assert.Equal(t, StateAdmitted, getRecord(t, f.ledger, 1).State, "withdrawn, it is work again")
+
+ tx2, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ defer func() { _ = tx2.Rollback() }()
+ require.Error(t, f.ledger.withdrawExposure(ctx, tx2, f.grant.ID, 1, StateAdmitted, ""), "once")
+ require.Error(t, f.ledger.withdrawExposure(ctx, tx2, f.grant.ID, 2, StateAdmitted, ""), "a sibling never exposed has nothing to withdraw")
+ _, err = tx2.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = 'again' WHERE event_id = 1`)
+ require.Error(t, err, "once, whoever writes")
+ _, err = tx2.ExecContext(ctx, `UPDATE task_events SET delivery = 'delivered' WHERE event_id = 1`)
+ require.Error(t, err, "a withdrawn exposure moves no more")
+ })
+ }
+ }
}
var allRecordStates = []RecordState{StateSeen, StateAdmitted, StateQueued, StateBlocked, StateDispatched, StateCompleted, StateDiscarded}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 2f34ee36e..beafd70fa 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -425,15 +425,34 @@ CREATE TABLE task_events (
links TEXT NOT NULL DEFAULT '[]',
reply_id INTEGER,
retired_at TEXT,
+ withdrawn_at TEXT,
PRIMARY KEY (task_id, event_id)
);
CREATE UNIQUE INDEX task_events_one_live_task ON task_events (event_id) WHERE retired_at IS NULL;
+CREATE INDEX task_events_event ON task_events (event_id, delivery);
+
+CREATE TRIGGER task_events_withdrawal_is_for_a_failed_spawn
+BEFORE UPDATE OF withdrawn_at ON task_events
+WHEN NEW.withdrawn_at IS NOT OLD.withdrawn_at AND (
+ OLD.withdrawn_at IS NOT NULL
+ OR OLD.delivery <> 'exposed'
+ OR NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = OLD.task_id AND tasks.superseded_at IS NOT NULL))
+BEGIN
+ SELECT RAISE(ABORT, 'only an exposure on a superseded task is withdrawn, and only once');
+END;
+
+CREATE TRIGGER task_events_withdrawn_is_final
+BEFORE UPDATE OF delivery ON task_events
+WHEN OLD.withdrawn_at IS NOT NULL AND NEW.delivery <> OLD.delivery
+BEGIN
+ SELECT RAISE(ABORT, 'a withdrawn exposure does not move');
+END;
CREATE TRIGGER events_handed_work_settles_first
BEFORE UPDATE OF state ON events
WHEN OLD.state = 'dispatched' AND NEW.state NOT IN ('dispatched', 'completed')
- AND EXISTS (SELECT 1 FROM task_events WHERE event_id = OLD.id AND delivery IN ('exposed', 'delivered'))
+ AND EXISTS (SELECT 1 FROM task_events WHERE event_id = OLD.id AND delivery IN ('exposed', 'delivered') AND withdrawn_at IS NULL)
BEGIN
SELECT RAISE(ABORT, 'a worker was handed this event; it leaves dispatched only when completed');
END;
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 10d295c1a..d6dfb3b99 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -47,7 +47,9 @@ import (
// admitted dispatched dispatcher (CreateTask) joins a task
// queued dispatched dispatcher (CreateTask) joins a task
// dispatched dispatched dispatcher (CreateTask) redispatch onto a new task
-// dispatched admitted dispatcher (SupersedeTask, withdrawal) never handed to a worker
+// dispatched admitted dispatcher (SupersedeTask) never handed to a worker
+// dispatched admitted dispatcher (withdrawExposure) exposed at launch, spawn proven failed: retry
+// dispatched blocked dispatcher (withdrawExposure) exposed at launch, spawn failed again
// dispatched blocked dispatcher never handed to a worker
// dispatched completed worker (complete_dispatch), dispatcher the outcome, reported or settled
// admitted queued lifecycle bookkeeping —
@@ -72,7 +74,11 @@ import (
// admitted → exposed worker (get_dispatch), dispatcher at launch
// exposed → delivered worker (ack_dispatch)
// exposed → completed worker (complete_dispatch)
+// exposed → completed dispatcher settlement (worker gone before ack)
// delivered → completed worker (complete_dispatch), dispatcher settlement
+// exposed → withdrawn dispatcher (withdrawExposure): the spawn failed
+// before any worker process existed; the task is
+// superseded first; once, and the row moves no more
//
// Forward only, and never skipping exposure: nothing a worker was never
// handed is acknowledged or completed. A row is retired (retired_at)
@@ -93,8 +99,13 @@ import (
// 3. The token is valid only while its task is live, checked inside every
// worker call's own transaction.
// 4. Nothing leaves dispatched while a worker may still act: a record with a
-// delivery exposed or delivered, on any task, leaves dispatched only to
-// completed (move, and the events_handed_work_settles_first trigger).
+// delivery exposed or delivered, on any task and not withdrawn, leaves
+// dispatched only to completed (move, and the
+// events_handed_work_settles_first trigger). The one release is the
+// spec's automatic retry: an exposure written at launch whose spawn
+// failed before any worker process existed is withdrawn, and the record
+// returns to admitted for its one retry, or goes to blocked after a
+// second failure (withdrawExposure).
// 5. A worker acts only on its own task's rows, reports only what it was
// handed, and a reported outcome stands.
// 6. A task is made only of instructions a worker can pull, and finished
@@ -357,6 +368,34 @@ func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) er
return nil
}
+// withdrawExposure releases an event whose worker never existed: the driver
+// proved the spawn itself failed, after the dispatcher had written the
+// originating event exposed at launch. It runs in the caller's transaction,
+// after supersedeTask on the same task. The record goes to admitted, to be
+// retried once, or — after a second failure — to blocked with reason. The
+// database refuses the marker for anything but an exposure on a superseded
+// task, and refuses it twice.
+func (l *Ledger) withdrawExposure(ctx context.Context, tx *sql.Tx, taskID, eventID int64, to RecordState, reason string) error {
+ if to != StateAdmitted && to != StateBlocked {
+ return fmt.Errorf("connector: withdraw event %d: a withdrawn event is retried (admitted) or blocked, not %s", eventID, to)
+ }
+ res, err := tx.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = ? WHERE task_id = ? AND event_id = ?`, l.timestamp(), taskID, eventID)
+ if err != nil {
+ return fmt.Errorf("connector: withdraw event %d: %w", eventID, err)
+ }
+ if n, err := res.RowsAffected(); err != nil || n == 0 {
+ return fmt.Errorf("connector: withdraw event %d: %w", eventID, ErrNotOnTask)
+ }
+ moved, err := l.move(ctx, tx, transition{id: eventID, state: to, reason: reason, from: []RecordState{StateDispatched}})
+ if err != nil {
+ return err
+ }
+ if !moved {
+ return fmt.Errorf("connector: withdraw event %d: another worker still holds it: %w", eventID, ErrHeldByWorker)
+ }
+ return nil
+}
+
// isConstraint reports a SQLite constraint violation.
func isConstraint(err error) bool {
var sqliteErr *sqlite.Error
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 292b8b600..ad2729203 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -932,3 +932,45 @@ func TestReportsDoNotHoldTheLedgerWhileTheyBuildTheirReceipt(t *testing.T) {
}
assert.Equal(t, 4, writes)
}
+
+// The spec's automatic retry, as a transition table of its own: the
+// dispatcher writes the originating event exposed at launch; the spawn is
+// proven to have failed before any worker process existed; the exposure is
+// withdrawn and the event launched again, once — and after a second failure it
+// is blocked. Without the withdrawal marker, an exposure holds the record in
+// dispatched and neither the retry nor the block is possible.
+func TestASpawnThatFailedIsRetriedOnceThenBlocked(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ exposeAtLaunch := func(taskID int64) {
+ t.Helper()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = 'launch' WHERE task_id = ? AND event_id = 1`, taskID)
+ require.NoError(t, err)
+ }
+ exposeAtLaunch(f.grant.ID)
+
+ // First failure: supersede, withdraw, launch again — one transaction.
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
+ require.NoError(t, f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, ""))
+ retry, err := f.ledger.createTask(ctx, tx, []int64{1, 2})
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit())
+ d, err := f.ledger.Dispatch(ctx, retry.Token, adapterAgentID)
+ require.NoError(t, err)
+ got, ok, err := d.Get(ctx, 0)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(1), got.EventID, "the retry hands out the originating event again")
+
+ // Second failure: supersede, withdraw, block.
+ tx, err = f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, retry.ID))
+ require.NoError(t, f.ledger.withdrawExposure(ctx, tx, retry.ID, 1, StateBlocked, "spawn_failed"))
+ require.NoError(t, tx.Commit())
+ record := getRecord(t, f.ledger, 1)
+ assert.Equal(t, StateBlocked, record.State)
+ assert.Equal(t, "spawn_failed", record.Reason)
+}
diff --git a/internal/connector/ledger_events.go b/internal/connector/ledger_events.go
index 18233f294..e842c4634 100644
--- a/internal/connector/ledger_events.go
+++ b/internal/connector/ledger_events.go
@@ -217,8 +217,10 @@ var lifecycle = map[RecordState][]RecordState{
StateAdmitted: {StateQueued, StateDispatched, StateBlocked, StateDiscarded},
StateQueued: {StateDispatched, StateBlocked, StateDiscarded},
StateBlocked: {StateAdmitted, StateQueued, StateDispatched, StateDiscarded},
- // A dispatched record whose worker never started has its exposure
- // withdrawn and returns to admitted. It is never discarded: a dispatched
+ // A dispatched record whose spawn failed before any worker process
+ // existed has its exposure withdrawn (task_events.withdrawn_at) and
+ // returns to admitted; one a worker was handed otherwise leaves only to
+ // completed (the dispatch lifecycle, ledger_dispatch.go). It is never discarded: a dispatched
// event ends completed, with an outcome, even when the outcome is
// unknown.
StateDispatched: {StateCompleted, StateBlocked, StateAdmitted},
@@ -391,9 +393,11 @@ func (l *Ledger) move(ctx context.Context, db dbtx, t transition) (bool, error)
}
// heldByWorker is true of a dispatched events row a worker was handed and
-// has not reported on: a delivery exposed or delivered, on any task.
+// has not reported on: a delivery exposed or delivered, on any task, and not
+// withdrawn because the spawn failed before any worker existed.
const heldByWorker = `state = 'dispatched' AND EXISTS (
- SELECT 1 FROM task_events WHERE task_events.event_id = events.id AND delivery IN ('exposed', 'delivered'))`
+ SELECT 1 FROM task_events WHERE task_events.event_id = events.id
+ AND delivery IN ('exposed', 'delivered') AND withdrawn_at IS NULL)`
// ErrHeldByWorker is a move out of dispatched for an event a worker was
// handed and has not reported on. Only its outcome moves it.
From 32fe533180b06fe5e48a1ce5015cc6429d3c5a71 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 11:59:16 +0200
Subject: [PATCH 13/33] Withdraw only what the ledger can see no worker had
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A withdrawal checked only that its own task was superseded. It could release
an event a new live task already carried, stranding it, and it could release
an exposure a worker had already pulled. A worker's first get_dispatch now
records pulled_at, even on an event exposed at launch, and the database
refuses a withdrawal after a pull or while any live task carries the event —
so the order is supersede, withdraw, create. That no process existed at all
remains the driver's report, and the table says so.
---
internal/commands/mcp_token_unix.go | 7 ++-
internal/connector/ledger.go | 14 ++++-
internal/connector/ledger_dispatch.go | 30 ++++++++---
internal/connector/ledger_dispatch_test.go | 62 +++++++++++++++++++---
4 files changed, 97 insertions(+), 16 deletions(-)
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_unix.go
index d6f609672..af944311c 100644
--- a/internal/commands/mcp_token_unix.go
+++ b/internal/commands/mcp_token_unix.go
@@ -28,7 +28,8 @@ import (
// uses.
//
// The read ends at the first newline or at end of file, and is bounded in
-// size and in time, so a write end left open somewhere cannot hang startup.
+// size and in time, so a write end left open somewhere cannot hang startup. A
+// sender writes "token\n", or closes its end after the token.
func readTaskToken(fd int) (string, error) {
switch {
case fd < 0:
@@ -44,7 +45,9 @@ func readTaskToken(fd int) (string, error) {
return "", output.ErrUsage(fmt.Sprintf("descriptor %d is not a pipe or a socket; the task token is handed over on one, never from a file", fd))
}
// Non-blocking before it is wrapped, so the runtime polls it and a read
- // deadline applies.
+ // deadline applies. The flag is on the open file description, so anything
+ // else sharing it would see it too; the connector's bridge execs this
+ // server, so nothing does.
if err := unix.SetNonblock(fd, true); err != nil {
return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index beafd70fa..717eb7ff6 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -425,6 +425,7 @@ CREATE TABLE task_events (
links TEXT NOT NULL DEFAULT '[]',
reply_id INTEGER,
retired_at TEXT,
+ pulled_at TEXT,
withdrawn_at TEXT,
PRIMARY KEY (task_id, event_id)
);
@@ -437,9 +438,18 @@ BEFORE UPDATE OF withdrawn_at ON task_events
WHEN NEW.withdrawn_at IS NOT OLD.withdrawn_at AND (
OLD.withdrawn_at IS NOT NULL
OR OLD.delivery <> 'exposed'
- OR NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = OLD.task_id AND tasks.superseded_at IS NOT NULL))
+ OR OLD.pulled_at IS NOT NULL
+ OR NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = OLD.task_id AND tasks.superseded_at IS NOT NULL)
+ OR EXISTS (SELECT 1 FROM task_events live WHERE live.event_id = OLD.event_id AND live.retired_at IS NULL))
BEGIN
- SELECT RAISE(ABORT, 'only an exposure on a superseded task is withdrawn, and only once');
+ SELECT RAISE(ABORT, 'only a launch exposure no worker pulled, on a superseded task and no live one, is withdrawn, and only once');
+END;
+
+CREATE TRIGGER task_events_pull_is_recorded_once
+BEFORE UPDATE OF pulled_at ON task_events
+WHEN OLD.pulled_at IS NOT NULL AND NEW.pulled_at IS NOT OLD.pulled_at
+BEGIN
+ SELECT RAISE(ABORT, 'a pull is recorded once');
END;
CREATE TRIGGER task_events_withdrawn_is_final
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index d6dfb3b99..0eaaf0650 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -77,8 +77,13 @@ import (
// exposed → completed dispatcher settlement (worker gone before ack)
// delivered → completed worker (complete_dispatch), dispatcher settlement
// exposed → withdrawn dispatcher (withdrawExposure): the spawn failed
-// before any worker process existed; the task is
-// superseded first; once, and the row moves no more
+// before any worker process existed. The database
+// holds what it can see: an exposure written at
+// launch that no worker ever pulled (pulled_at, set by
+// a worker's first get_dispatch), on a superseded task,
+// with no live task carrying the event; once, and the
+// row moves no more. That no process existed at all is
+// the driver's report, which the dispatcher acts on.
//
// Forward only, and never skipping exposure: nothing a worker was never
// handed is acknowledged or completed. A row is retired (retired_at)
@@ -373,8 +378,10 @@ func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) er
// originating event exposed at launch. It runs in the caller's transaction,
// after supersedeTask on the same task. The record goes to admitted, to be
// retried once, or — after a second failure — to blocked with reason. The
-// database refuses the marker for anything but an exposure on a superseded
-// task, and refuses it twice.
+// database refuses the marker for anything but a launch exposure no worker
+// pulled, on a superseded task, while no live task carries the event, and
+// refuses it twice. So the order is supersede, withdraw, then create the
+// retry task.
func (l *Ledger) withdrawExposure(ctx context.Context, tx *sql.Tx, taskID, eventID int64, to RecordState, reason string) error {
if to != StateAdmitted && to != StateBlocked {
return fmt.Errorf("connector: withdraw event %d: a withdrawn event is retried (admitted) or blocked, not %s", eventID, to)
@@ -531,6 +538,7 @@ func (d *TaskDispatch) Get(ctx context.Context, eventID int64) (Instruction, boo
}
type taskEvent struct {
+ pulled bool
delivery Delivery
guard string
ackID sql.NullInt64
@@ -587,6 +595,16 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
}
wrote = true
}
+ if !te.pulled {
+ // The first pull by a worker is recorded even when the dispatcher
+ // already exposed the event at launch: from here on a worker has the
+ // instruction, and the exposure can no longer be withdrawn as a
+ // spawn that failed before any worker existed.
+ if _, err := tx.ExecContext(ctx, `UPDATE task_events SET pulled_at = ? WHERE task_id = ? AND event_id = ? AND pulled_at IS NULL`, now, taskID, eventID); err != nil {
+ return Instruction{}, false, fmt.Errorf("connector: record the pull of %d: %w", eventID, err)
+ }
+ wrote = true
+ }
if te.guard == "armed" {
if _, err := tx.ExecContext(ctx, `UPDATE task_events SET guard = 'canceled' WHERE task_id = ? AND event_id = ? AND guard = 'armed'`, taskID, eventID); err != nil {
return Instruction{}, false, fmt.Errorf("connector: cancel guard on %d: %w", eventID, err)
@@ -804,8 +822,8 @@ func loadTaskEvent(ctx context.Context, tx *sql.Tx, taskID, eventID int64) (task
delivery string
)
err := tx.QueryRowContext(ctx, `
-SELECT delivery, guard, ack_id, outcome, links, reply_id
-FROM task_events WHERE task_id = ? AND event_id = ?`, taskID, eventID).Scan(&delivery, &te.guard, &te.ackID, &te.outcome, &te.links, &te.replyID)
+SELECT delivery, guard, ack_id, outcome, links, reply_id, pulled_at IS NOT NULL
+FROM task_events WHERE task_id = ? AND event_id = ?`, taskID, eventID).Scan(&delivery, &te.guard, &te.ackID, &te.outcome, &te.links, &te.replyID, &te.pulled)
if errors.Is(err, sql.ErrNoRows) {
return te, fmt.Errorf("connector: event %d: %w", eventID, ErrNotOnTask)
}
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index ad2729203..7697a3e1b 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -957,12 +957,11 @@ func TestASpawnThatFailedIsRetriedOnceThenBlocked(t *testing.T) {
retry, err := f.ledger.createTask(ctx, tx, []int64{1, 2})
require.NoError(t, err)
require.NoError(t, tx.Commit())
- d, err := f.ledger.Dispatch(ctx, retry.Token, adapterAgentID)
- require.NoError(t, err)
- got, ok, err := d.Get(ctx, 0)
- require.NoError(t, err)
- require.True(t, ok)
- assert.Equal(t, int64(1), got.EventID, "the retry hands out the originating event again")
+ var onRetry int
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_events WHERE task_id = ? AND event_id = 1 AND retired_at IS NULL`, retry.ID).Scan(&onRetry))
+ assert.Equal(t, 1, onRetry, "the retry carries the originating event again")
+ assert.Equal(t, StateDispatched, getRecord(t, f.ledger, 1).State)
+ exposeAtLaunch(retry.ID)
// Second failure: supersede, withdraw, block.
tx, err = f.ledger.db.BeginTx(ctx, nil)
@@ -974,3 +973,54 @@ func TestASpawnThatFailedIsRetriedOnceThenBlocked(t *testing.T) {
assert.Equal(t, StateBlocked, record.State)
assert.Equal(t, "spawn_failed", record.Reason)
}
+
+// What the database can see of "no worker existed", it holds: a withdrawal is
+// refused once a worker pulled the instruction, and refused while a live task
+// carries the event — so the only order is supersede, withdraw, create.
+func TestAWithdrawalIsRefusedWhenAWorkerCouldHaveTheInstruction(t *testing.T) {
+ t.Run("a worker pulled it", func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err, "the worker pulls an event exposed at launch")
+
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
+ require.Error(t, f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, ""))
+ })
+
+ t.Run("a live task already carries it", func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
+ _, err = f.ledger.createTask(ctx, tx, []int64{1, 2})
+ require.NoError(t, err)
+ require.Error(t, f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, ""), "create before withdraw is the wrong order")
+ })
+
+ t.Run("a pull is recorded once, and a repeat writes nothing", func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ var first string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT pulled_at FROM task_events WHERE event_id = 1`).Scan(&first))
+ _, _, err = f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ var again string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT pulled_at FROM task_events WHERE event_id = 1`).Scan(&again))
+ assert.Equal(t, first, again)
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'later' WHERE event_id = 1`)
+ require.Error(t, err)
+ })
+}
From b146b57a505d0414537b35a9c41e255fdd09816a Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 12:16:36 +0200
Subject: [PATCH 14/33] A record is dispatched exactly while a live task
carries it, in the database
A plain state write could still enter dispatched with no task, or leave it
while a live task still carried the event, which could then block the event
from ever joining another task. Both are now refused by the ledger's write
(ErrNotOnALiveTask, ErrOnALiveTask) and by a trigger, so dispatch goes
through createTask, supersedeTask and withdrawExposure alone. The lifecycle
table test tries every pair both through the write and, for the rules the
database owns, around it; tests that walked a record to dispatched by hand
now dispatch it on a task.
complete_dispatch also checks the token and the exposure before it reads the
report, so a superseded worker is told so whatever it sent.
---
internal/connector/dispatch_lifecycle_test.go | 44 ++++++++++----
internal/connector/invariants_test.go | 16 ++---
internal/connector/ledger.go | 11 ++++
internal/connector/ledger_admission_test.go | 25 ++++----
internal/connector/ledger_dispatch.go | 36 ++++++-----
internal/connector/ledger_dispatch_test.go | 59 +++++++++++++++----
internal/connector/ledger_events.go | 41 +++++++++++--
internal/connector/round7_test.go | 3 +-
8 files changed, 167 insertions(+), 68 deletions(-)
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index 79c9927ad..a66b50377 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -75,22 +75,40 @@ func testWithdrawal(t *testing.T) {
var allRecordStates = []RecordState{StateSeen, StateAdmitted, StateQueued, StateBlocked, StateDispatched, StateCompleted, StateDiscarded}
-// recordTable is the record table: from → the states a move may reach, the
-// state itself (a repeat) excluded. heldRecordTable is dispatched when a
-// worker was handed the event.
+// recordTable is the record table as a plain state write sees it: from → the
+// states SetState may reach, the state itself (a repeat) excluded. Into
+// dispatched and out of it is the task's business — a record enters only
+// when a live task carries it, and leaves (but to completed) only when none
+// does — so a plain write finds no way in, and from a dispatched record on a
+// live task only completed. refusedFor says which refusal each such pair gets.
+// heldRecordTable is dispatched when a worker was handed the event.
var (
recordTable = map[RecordState][]RecordState{
StateSeen: {StateAdmitted, StateQueued, StateBlocked, StateDiscarded},
- StateAdmitted: {StateQueued, StateDispatched, StateBlocked, StateDiscarded},
- StateQueued: {StateDispatched, StateBlocked, StateDiscarded},
- StateBlocked: {StateAdmitted, StateQueued, StateDispatched, StateDiscarded},
- StateDispatched: {StateCompleted, StateBlocked, StateAdmitted},
+ StateAdmitted: {StateQueued, StateBlocked, StateDiscarded},
+ StateQueued: {StateBlocked, StateDiscarded},
+ StateBlocked: {StateAdmitted, StateQueued, StateDiscarded},
+ StateDispatched: {StateCompleted},
StateCompleted: nil,
StateDiscarded: nil,
}
heldRecordTable = []RecordState{StateCompleted}
)
+// refusedFor is the refusal a pair outside the table gets.
+func refusedFor(from, to RecordState, held bool) error {
+ switch {
+ case held && (to == StateAdmitted || to == StateBlocked):
+ return ErrHeldByWorker
+ case to == StateDispatched && slices.Contains([]RecordState{StateAdmitted, StateQueued, StateBlocked}, from):
+ return ErrNotOnALiveTask
+ case from == StateDispatched && (to == StateAdmitted || to == StateBlocked):
+ return ErrOnALiveTask
+ default:
+ return ErrNotATransition
+ }
+}
+
// reachRecord puts event 1 in state, handed to a worker when held.
func reachRecord(t *testing.T, ledger *Ledger, state RecordState, held bool) {
t.Helper()
@@ -164,10 +182,14 @@ func testRecordTransitions(t *testing.T) {
}
require.Error(t, err)
assert.Equal(t, from, getRecord(t, ledger, 1).State, "a refused move moves nothing")
- if held {
- assert.ErrorIs(t, err, ErrHeldByWorker)
- } else {
- assert.ErrorIs(t, err, ErrNotATransition)
+ assert.ErrorIs(t, err, refusedFor(from, to, held))
+ // The dispatch and terminal rules are the database's too, so
+ // they refuse whoever writes; the rest of the lifecycle map
+ // is the ledger's write to keep.
+ refusal := refusedFor(from, to, held)
+ if refusal != ErrNotATransition || from == StateCompleted || from == StateDiscarded {
+ _, rawErr := ledger.db.ExecContext(context.Background(), `UPDATE events SET state = ?, reason = ? WHERE id = 1`, string(to), reasonFor(to))
+ assert.Error(t, rawErr, "a raw write of %s to %s", from, to)
}
})
}
diff --git a/internal/connector/invariants_test.go b/internal/connector/invariants_test.go
index 596262e02..e39605883 100644
--- a/internal/connector/invariants_test.go
+++ b/internal/connector/invariants_test.go
@@ -339,12 +339,7 @@ func reachTerminal(t *testing.T, ledger *Ledger, id int64, terminal RecordState)
if terminal == StateDiscarded {
return ledger.SetState(ctx, id, StateDiscarded, "untrusted_author")
}
- if err := ledger.SetState(ctx, id, StateAdmitted, ""); err != nil {
- return err
- }
- if err := ledger.SetState(ctx, id, StateDispatched, ""); err != nil {
- return err
- }
+ dispatchForTest(t, ledger, id)
return ledger.SetState(ctx, id, StateCompleted, "")
}
@@ -421,17 +416,16 @@ func TestInvariantE4TheLifecycleCarriesTheEdgesLaterCardsCommit(t *testing.T) {
assert.Equal(t, StateQueued, record.State)
})
- // A dispatched record whose worker never started has its exposure
- // withdrawn and returns to admitted.
+ // A dispatched record never handed to a worker returns to admitted when
+ // its task is superseded.
t.Run("dispatched back to admitted", func(t *testing.T) {
ledger := newTestLedger(t)
ctx := context.Background()
_, err := ledger.RecordSeen(ctx, testEvent(1), LanePoll)
require.NoError(t, err)
- require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, ""))
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ grant := dispatchForTest(t, ledger, 1)
- require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, ""))
+ require.NoError(t, ledger.SupersedeTask(ctx, grant.ID))
record, ok, err := ledger.Get(ctx, 1)
require.NoError(t, err)
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 717eb7ff6..e732b1e3d 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -467,6 +467,17 @@ BEGIN
SELECT RAISE(ABORT, 'a worker was handed this event; it leaves dispatched only when completed');
END;
+CREATE TRIGGER events_dispatched_while_on_a_live_task
+BEFORE UPDATE OF state ON events
+WHEN NEW.state <> OLD.state AND (
+ (NEW.state = 'dispatched'
+ AND NOT EXISTS (SELECT 1 FROM task_events WHERE event_id = OLD.id AND retired_at IS NULL))
+ OR (OLD.state = 'dispatched' AND NEW.state <> 'completed'
+ AND EXISTS (SELECT 1 FROM task_events WHERE event_id = OLD.id AND retired_at IS NULL)))
+BEGIN
+ SELECT RAISE(ABORT, 'a record is dispatched exactly while a live task carries it');
+END;
+
CREATE TRIGGER task_events_guard_settles_once
BEFORE UPDATE OF guard ON task_events
WHEN NEW.guard <> OLD.guard AND NOT (OLD.guard = 'armed' AND NEW.guard IN ('canceled', 'fired'))
diff --git a/internal/connector/ledger_admission_test.go b/internal/connector/ledger_admission_test.go
index 0ce55ca1f..6caeb4703 100644
--- a/internal/connector/ledger_admission_test.go
+++ b/internal/connector/ledger_admission_test.go
@@ -122,12 +122,10 @@ func TestAdmissionSkipsARecordPastDeciding(t *testing.T) {
require.NoError(t, ledger.SetState(ctx, 1, StateDiscarded, "untrusted_author"))
case StateCompleted:
require.NoError(t, reachTerminal(t, ledger, 7, StateCompleted))
- require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, ""))
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ dispatchForTest(t, ledger, 1)
require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, ""))
case StateDispatched:
- require.NoError(t, ledger.SetState(ctx, 1, StateAdmitted, ""))
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ dispatchForTest(t, ledger, 1)
default:
require.NoError(t, ledger.SetState(ctx, 1, state, ""))
}
@@ -245,8 +243,7 @@ func TestAdmissionNeverDecidesARecordPastDeciding(t *testing.T) {
require.NoError(t, l.SetState(context.Background(), 1, StateAdmitted, ""))
},
"dispatched": func(t *testing.T, l *Ledger) {
- require.NoError(t, l.SetState(context.Background(), 1, StateAdmitted, ""))
- require.NoError(t, l.SetState(context.Background(), 1, StateDispatched, ""))
+ dispatchForTest(t, l, 1)
},
"discarded": func(t *testing.T, l *Ledger) {
require.NoError(t, l.SetState(context.Background(), 1, StateDiscarded, "by_operator"))
@@ -297,21 +294,21 @@ func TestAdmissionQueuesBehindALiveConversation(t *testing.T) {
{"a dispatched record is a running task", func(t *testing.T, l *Ledger) {
_, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key))
require.NoError(t, err)
- require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, ""))
+ dispatchForTest(t, l, 2)
}, admission.StateQueued},
{"a queued record alone is not a task", func(t *testing.T, l *Ledger) {
_, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key))
require.NoError(t, err)
_, err = l.Admission().Commit(context.Background(), admittedVerdict(3, 0, key))
require.NoError(t, err)
- require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, ""))
+ dispatchForTest(t, l, 2)
require.NoError(t, l.SetState(context.Background(), 2, StateCompleted, ""))
require.Equal(t, StateQueued, getRecord(t, l, 3).State)
}, admission.StateAdmitted},
{"a completed task is not live", func(t *testing.T, l *Ledger) {
_, err := l.Admission().Commit(context.Background(), admittedVerdict(2, 0, key))
require.NoError(t, err)
- require.NoError(t, l.SetState(context.Background(), 2, StateDispatched, ""))
+ dispatchForTest(t, l, 2)
require.NoError(t, l.SetState(context.Background(), 2, StateCompleted, ""))
}, admission.StateAdmitted},
{"a blocked record is not a task", func(t *testing.T, l *Ledger) {
@@ -462,8 +459,10 @@ func TestEveryMoveKeepsTheBlockedScheduleInputs(t *testing.T) {
assert.Nil(t, d.RetryAt)
// Back into blocked after a dispatch: a new window from now, and the
- // verdict that follows keeps it.
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ // verdict that follows keeps it. A record leaves dispatched when its task
+ // is superseded.
+ grant := dispatchForTest(t, ledger, 1)
+ require.NoError(t, ledger.SupersedeTask(ctx, grant.ID))
require.NoError(t, ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
record := getRecord(t, ledger, 1)
require.NotNil(t, record.Decision.BlockedAt)
@@ -506,7 +505,7 @@ func TestAMoveToBlockedOrDiscardedDropsTheSnapshot(t *testing.T) {
seenRecord(t, ledger, 1)
_, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
require.NoError(t, err)
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ dispatchForTest(t, ledger, 1)
require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, ""))
assert.NotEmpty(t, getRecord(t, ledger, 1).Decision.Snapshot)
})
@@ -571,7 +570,7 @@ func TestDropContentTakesTheVerdictToo(t *testing.T) {
seenRecord(t, ledger, 1)
_, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
require.NoError(t, err)
- require.NoError(t, ledger.SetState(ctx, 1, StateDispatched, ""))
+ dispatchForTest(t, ledger, 1)
require.NoError(t, ledger.SetState(ctx, 1, StateCompleted, ""))
dropped, err := ledger.DropContent(ctx, at.Add(time.Hour), at.Add(time.Hour))
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 0eaaf0650..246d65783 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -43,11 +43,11 @@ import (
// blocked queued admission re-decided, conversation live
// blocked blocked admission re-decided, still blocked
// blocked discarded admission, operator verdict, or discard
-// blocked dispatched lifecycle bookkeeping —
+// blocked dispatched dispatcher (CreateTask) redispatch of a blocked record
// admitted dispatched dispatcher (CreateTask) joins a task
// queued dispatched dispatcher (CreateTask) joins a task
// dispatched dispatched dispatcher (CreateTask) redispatch onto a new task
-// dispatched admitted dispatcher (SupersedeTask) never handed to a worker
+// dispatched admitted dispatcher (SupersedeTask) never handed to a worker, task retired
// dispatched admitted dispatcher (withdrawExposure) exposed at launch, spawn proven failed: retry
// dispatched blocked dispatcher (withdrawExposure) exposed at launch, spawn failed again
// dispatched blocked dispatcher never handed to a worker
@@ -61,7 +61,10 @@ import (
// discarded — nobody terminal
//
// Writing the state a record already has is a repeat and always allowed. Any
-// pair not in the table is refused.
+// pair not in the table is refused. Into dispatched and out of it, the task
+// decides: a record enters dispatched only when a live task already carries
+// it, and leaves it — other than to completed — only when none does
+// (events_dispatched_while_on_a_live_task, and move).
//
// # Task (tasks)
//
@@ -712,21 +715,24 @@ WHERE task_id = ? AND event_id = ?`, d.ledger.timestamp(), nullableID(ackID), ta
// answers the same receipt; a different one is refused, because a reported
// outcome stands.
func (d *TaskDispatch) Complete(ctx context.Context, eventID int64, c Completion) (Receipt, error) {
- if c.Outcome != OutcomeSucceeded && c.Outcome != OutcomeFailed {
- return Receipt{}, fmt.Errorf("connector: outcome must be %q or %q: %w", OutcomeSucceeded, OutcomeFailed, ErrInvalidReport)
- }
- links, err := normalizeLinks(c.Links)
- if err != nil {
- return Receipt{}, err
- }
- encoded, err := json.Marshal(links)
- if err != nil {
- return Receipt{}, err
- }
var out Receipt
- err = retryBusy(func() error {
+ err := retryBusy(func() error {
var err error
out, err = d.report(ctx, eventID, func(ctx context.Context, tx *sql.Tx, taskID int64, te taskEvent) (bool, error) {
+ // Validated inside the call's transaction, after the token and
+ // the exposure are: a worker whose task was superseded is told
+ // that, whatever it sent.
+ if c.Outcome != OutcomeSucceeded && c.Outcome != OutcomeFailed {
+ return false, fmt.Errorf("connector: outcome must be %q or %q: %w", OutcomeSucceeded, OutcomeFailed, ErrInvalidReport)
+ }
+ links, err := normalizeLinks(c.Links)
+ if err != nil {
+ return false, err
+ }
+ encoded, err := json.Marshal(links)
+ if err != nil {
+ return false, err
+ }
if te.delivery == DeliveryCompleted {
if te.outcome == string(c.Outcome) && te.links == string(encoded) && sameID(te.replyID, c.ReplyID) {
return false, nil
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 7697a3e1b..f2d34502f 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -188,7 +188,6 @@ func TestGetDispatchCancelsTheGuardAndReportsAFiredOne(t *testing.T) {
func TestGetDispatchCancelsAnArmedGuardOnAnAlreadyExposedEvent(t *testing.T) {
f := newDispatchFixture(t)
// Exposed at launch by the dispatcher, guard still armed.
- require.NoError(t, f.ledger.SetState(context.Background(), 1, StateDispatched, ""))
_, err := f.ledger.db.ExecContext(context.Background(), `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
require.NoError(t, err)
@@ -328,18 +327,16 @@ func TestAWorkerSeesOnlyItsOwnTask(t *testing.T) {
func TestAnEventThatLeftThePathIsNotHandedOut(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
- require.NoError(t, f.ledger.SetState(ctx, 2, StateBlocked, "no_route"))
+ // A record on a live task cannot be taken off the path around its task.
+ require.ErrorIs(t, f.ledger.SetState(ctx, 2, StateBlocked, "no_route"), ErrOnALiveTask)
+
+ // It can be settled before a worker pulls it: finished work is not
+ // handed out for the first time.
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateCompleted, ""))
_, _, err := f.d.Get(ctx, 2)
assert.ErrorIs(t, err, ErrNotDispatchable)
assert.Equal(t, "admitted", f.row(t, 2).Delivery)
-
- // Withdrawn back to admitted, content and all: not a worker's any more.
- require.NoError(t, f.ledger.SetState(ctx, 1, StateAdmitted, ""))
- require.NotEmpty(t, getRecord(t, f.ledger, 1).Decision.Snapshot)
- _, _, err = f.d.Get(ctx, 1)
- assert.ErrorIs(t, err, ErrNotDispatchable)
- assert.Equal(t, "admitted", f.row(t, 1).Delivery)
}
// Retention took the instruction: a completed event asked for again answers
@@ -446,8 +443,8 @@ func TestCreateTaskInsideACallersTransaction(t *testing.T) {
func TestCreateTaskRefusesARecordWithoutItsInstruction(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
- require.NoError(t, f.ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateBlocked, "read_failed"), "never handed, it left dispatched with its task")
require.NoError(t, f.ledger.SetState(ctx, 1, StateAdmitted, ""))
_, err := f.ledger.CreateTask(ctx, []int64{1})
@@ -609,7 +606,7 @@ func TestConcurrentLaunchesOfOneEventMakeOneTask(t *testing.T) {
func TestTheEarliestSkipsAnEventThatLeftThePath(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
- require.NoError(t, f.ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""), "settled before any worker pulled it")
got, ok, err := f.d.Get(ctx, 0)
require.NoError(t, err)
@@ -1024,3 +1021,43 @@ func TestAWithdrawalIsRefusedWhenAWorkerCouldHaveTheInstruction(t *testing.T) {
require.Error(t, err)
})
}
+
+// A worker whose task was superseded is told so, whatever it sends: the token
+// is checked before the report is.
+func TestASupersededWorkerIsRefusedBeforeItsReportIsRead(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+
+ for name, c := range map[string]Completion{
+ "no outcome": {},
+ "not a URL": {Outcome: OutcomeSucceeded, Links: []string{"javascript:alert(1)"}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := f.d.Complete(ctx, 1, c)
+ require.ErrorIs(t, err, ErrTaskTokenRefused)
+ assert.NotErrorIs(t, err, ErrInvalidReport)
+ })
+ }
+}
+
+// dispatchForTest dispatches a record the only way a record is dispatched: on
+// a task. A record a test walked to admitted by hand has no instruction, so one
+// is attached first; a seen record is admitted first.
+func dispatchForTest(t *testing.T, ledger *Ledger, id int64) TaskGrant {
+ t.Helper()
+ ctx := context.Background()
+ _, err := ledger.db.ExecContext(ctx, `UPDATE events
+SET snapshot = COALESCE(snapshot, CAST('{"content":"do it"}' AS BLOB)),
+ conversation_key = CASE WHEN conversation_key = '' THEN 'recording:' || id ELSE conversation_key END
+WHERE id = ?`, id)
+ require.NoError(t, err)
+ if getRecord(t, ledger, id).State == StateSeen {
+ require.NoError(t, ledger.SetState(ctx, id, StateAdmitted, ""))
+ }
+ grant, err := ledger.CreateTask(ctx, []int64{id})
+ require.NoError(t, err)
+ return grant
+}
diff --git a/internal/connector/ledger_events.go b/internal/connector/ledger_events.go
index e842c4634..4e278c040 100644
--- a/internal/connector/ledger_events.go
+++ b/internal/connector/ledger_events.go
@@ -369,10 +369,20 @@ func (l *Ledger) move(ctx context.Context, db dbtx, t transition) (bool, error)
args = append(args, *t.revision)
}
query.WriteString(" AND state IN (" + strings.TrimSuffix(strings.Repeat("?, ", len(froms)), ", ") + ")")
- if t.state != StateDispatched && t.state != StateCompleted {
+ switch t.state {
+ case StateDispatched:
+ // A record is dispatched exactly while a live task carries it: it
+ // enters dispatched only with its task row already written
+ // (createTask), and a repeat is a repeat.
+ query.WriteString(" AND (state = 'dispatched' OR " + onLiveTask + ")")
+ case StateCompleted:
+ default:
// Invariant 4 of the dispatch lifecycle (ledger_dispatch.go): a
- // record a worker was handed leaves dispatched only to completed.
+ // record a worker was handed leaves dispatched only to completed —
+ // and any other record leaves it only once no live task carries it
+ // (supersedeTask retires the row first).
query.WriteString(" AND NOT (" + heldByWorker + ")")
+ query.WriteString(" AND NOT (state = 'dispatched' AND " + onLiveTask + ")")
}
for _, from := range froms {
args = append(args, from)
@@ -399,6 +409,18 @@ const heldByWorker = `state = 'dispatched' AND EXISTS (
SELECT 1 FROM task_events WHERE task_events.event_id = events.id
AND delivery IN ('exposed', 'delivered') AND withdrawn_at IS NULL)`
+// onLiveTask is true of an events row a live task carries.
+const onLiveTask = `EXISTS (
+ SELECT 1 FROM task_events WHERE task_events.event_id = events.id AND retired_at IS NULL)`
+
+// ErrNotOnALiveTask is a move into dispatched for a record no live task
+// carries. Records are dispatched by creating a task for them.
+var ErrNotOnALiveTask = errors.New("no live task carries this event; a record is dispatched by creating a task for it")
+
+// ErrOnALiveTask is a move out of dispatched, other than to completed, for a
+// record a live task still carries. Its task is superseded first.
+var ErrOnALiveTask = errors.New("a live task still carries this event; supersede the task first")
+
// ErrHeldByWorker is a move out of dispatched for an event a worker was
// handed and has not reported on. Only its outcome moves it.
var ErrHeldByWorker = errors.New("a worker was handed this event; it leaves dispatched only when completed")
@@ -417,10 +439,19 @@ func (l *Ledger) explainRefusal(ctx context.Context, id int64, state RecordState
case err != nil:
return fmt.Errorf("connector: set state of %d: %w", id, err)
}
- if RecordState(current) == StateDispatched && state != StateDispatched && state != StateCompleted {
- var held bool
- if err := l.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM events WHERE id = ? AND `+heldByWorker+`)`, id).Scan(&held); err == nil && held {
+ if slices.Contains(enterableFrom(state), current) {
+ // The edge exists; a dispatch rule refused it.
+ var held, live bool
+ if err := l.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM events WHERE id = ? AND `+heldByWorker+`), EXISTS (SELECT 1 FROM events WHERE id = ? AND `+onLiveTask+`)`, id, id).Scan(&held, &live); err != nil {
+ return fmt.Errorf("connector: set state of %d: %w", id, err)
+ }
+ switch {
+ case held:
return fmt.Errorf("connector: set state of %d: %w", id, ErrHeldByWorker)
+ case state == StateDispatched && !live:
+ return fmt.Errorf("connector: set state of %d: %w", id, ErrNotOnALiveTask)
+ case live:
+ return fmt.Errorf("connector: set state of %d: %w", id, ErrOnALiveTask)
}
}
return fmt.Errorf("connector: set state of %d: %s to %s is %w", id, current, state, ErrNotATransition)
diff --git a/internal/connector/round7_test.go b/internal/connector/round7_test.go
index b55e73c6d..d294ede2e 100644
--- a/internal/connector/round7_test.go
+++ b/internal/connector/round7_test.go
@@ -40,8 +40,7 @@ func TestDropContentKeepsARecordUpdatedJustAfterAWholeSecondCutoff(t *testing.T)
_, err := ledger.RecordSeen(ctx, testEvent(43), LanePoll)
require.NoError(t, err)
// The lifecycle has no shortcut to completed, so the record walks there.
- require.NoError(t, ledger.SetState(ctx, 43, StateAdmitted, ""))
- require.NoError(t, ledger.SetState(ctx, 43, StateDispatched, ""))
+ dispatchForTest(t, ledger, 43)
require.NoError(t, ledger.SetState(ctx, 43, StateCompleted, ""))
dropped, err := ledger.DropContent(ctx, cutoff, cutoff)
From 95fe772abe7d8c2a49632bd3b8c1400b213dc78d Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 12:30:20 +0200
Subject: [PATCH 15/33] Make the lifecycle table say what the code does; use
the checked directory
Three table rows promised transitions no caller can take or described a state
rule where the database holds a rule about moves: blocked to dispatched exists
in the lifecycle map but no task can take a record with no instruction;
dispatched to blocked is only the second spawn failure's withdrawal; and a
record enters dispatched only on a live task and leaves (but to completed)
only without one, so work a worker holds after its task was superseded is
dispatched with no live task, as invariant 7 already says.
ResolveStateDir returns the directory it checked, and the command opens the
ledger and words its messages from that one. A worker's refusal reaches it
without the ledger's report wrapping in the middle. The token read looks for
the newline without allocating.
---
internal/commands/mcp.go | 8 ++--
internal/commands/mcp_token_unix.go | 2 +-
internal/connector/ledger_admission_test.go | 7 ++--
internal/connector/ledger_dispatch.go | 41 ++++++++++++++-------
internal/connector/ledger_dispatch_test.go | 11 ++++--
internal/connector/strip_mentions_test.go | 6 +--
6 files changed, 47 insertions(+), 28 deletions(-)
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index fcb29db69..cd0f55c10 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -176,7 +176,7 @@ func stateDirHint(refusal *connector.StateDirError) string {
// connector's ledger, it never starts one.
func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
- agentID, err := connector.ResolveStateDir(stateDir, accountID)
+ dir, agentID, err := connector.ResolveStateDir(stateDir, accountID)
if err != nil {
var refusal *connector.StateDirError
if errors.As(err, &refusal) {
@@ -189,10 +189,10 @@ func openConnectDispatch(ctx context.Context, stateDir, accountID, token string)
// The connector owns the ledger: a worker's server opens it as it is, and
// never creates or migrates it.
- ledger, err := connector.OpenExistingLedger(ctx, filepath.Join(stateDir, connector.LedgerFile))
+ ledger, err := connector.OpenExistingLedger(ctx, filepath.Join(dir, connector.LedgerFile))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- return nil, nil, output.ErrUsage(fmt.Sprintf("no connector ledger in %s", stateDir))
+ return nil, nil, output.ErrUsage(fmt.Sprintf("no connector ledger in %s", dir))
}
return nil, nil, err
}
@@ -200,7 +200,7 @@ func openConnectDispatch(ctx context.Context, stateDir, accountID, token string)
if err != nil {
_ = ledger.Close()
if errors.Is(err, connector.ErrTaskTokenRefused) {
- return nil, nil, output.ErrUsage("the task token names no current task in " + stateDir)
+ return nil, nil, output.ErrUsage("the task token names no current task in " + dir)
}
return nil, nil, err
}
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_unix.go
index af944311c..793b50fd1 100644
--- a/internal/commands/mcp_token_unix.go
+++ b/internal/commands/mcp_token_unix.go
@@ -59,7 +59,7 @@ func readTaskToken(fd int) (string, error) {
var data []byte
buf := make([]byte, 256)
- for len(data) <= maxTaskTokenBytes && !bytes.Contains(data, []byte("\n")) {
+ for len(data) <= maxTaskTokenBytes && bytes.IndexByte(data, '\n') < 0 {
n, err := file.Read(buf)
data = append(data, buf[:n]...)
if err == nil {
diff --git a/internal/connector/ledger_admission_test.go b/internal/connector/ledger_admission_test.go
index 6caeb4703..a540a8562 100644
--- a/internal/connector/ledger_admission_test.go
+++ b/internal/connector/ledger_admission_test.go
@@ -458,9 +458,10 @@ func TestEveryMoveKeepsTheBlockedScheduleInputs(t *testing.T) {
assert.Nil(t, d.BlockedAt, "a record that left blocked is not blocked")
assert.Nil(t, d.RetryAt)
- // Back into blocked after a dispatch: a new window from now, and the
- // verdict that follows keeps it. A record leaves dispatched when its task
- // is superseded.
+ // Blocked again after a dispatch that was superseded: a new window from
+ // now, and the verdict that follows keeps it. Superseding a task returns
+ // an event no worker was handed to admitted, and it is blocked from
+ // there.
grant := dispatchForTest(t, ledger, 1)
require.NoError(t, ledger.SupersedeTask(ctx, grant.ID))
require.NoError(t, ledger.SetState(ctx, 1, StateBlocked, "read_failed"))
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 246d65783..a57fc4ee7 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -43,14 +43,15 @@ import (
// blocked queued admission re-decided, conversation live
// blocked blocked admission re-decided, still blocked
// blocked discarded admission, operator verdict, or discard
-// blocked dispatched dispatcher (CreateTask) redispatch of a blocked record
+// blocked dispatched — an edge the lifecycle map has and no
+// caller can take: a task needs an
+// instruction, and blocked has none
// admitted dispatched dispatcher (CreateTask) joins a task
// queued dispatched dispatcher (CreateTask) joins a task
// dispatched dispatched dispatcher (CreateTask) redispatch onto a new task
// dispatched admitted dispatcher (SupersedeTask) never handed to a worker, task retired
// dispatched admitted dispatcher (withdrawExposure) exposed at launch, spawn proven failed: retry
// dispatched blocked dispatcher (withdrawExposure) exposed at launch, spawn failed again
-// dispatched blocked dispatcher never handed to a worker
// dispatched completed worker (complete_dispatch), dispatcher the outcome, reported or settled
// admitted queued lifecycle bookkeeping —
// admitted blocked lifecycle bookkeeping —
@@ -62,9 +63,12 @@ import (
//
// Writing the state a record already has is a repeat and always allowed. Any
// pair not in the table is refused. Into dispatched and out of it, the task
-// decides: a record enters dispatched only when a live task already carries
-// it, and leaves it — other than to completed — only when none does
-// (events_dispatched_while_on_a_live_task, and move).
+// decides, as a rule about the moves rather than about the state: a record
+// enters dispatched only when a live task already carries it, and leaves it —
+// other than to completed — only when none does
+// (events_dispatched_while_on_a_live_task, and move). A record can therefore
+// be dispatched with no live task, and one case is expected: work a worker was
+// handed, whose task was superseded, waiting for its outcome (invariant 7).
//
// # Task (tasks)
//
@@ -113,7 +117,8 @@ import (
// spec's automatic retry: an exposure written at launch whose spawn
// failed before any worker process existed is withdrawn, and the record
// returns to admitted for its one retry, or goes to blocked after a
-// second failure (withdrawExposure).
+// second failure (withdrawExposure) — the only way from dispatched to
+// blocked.
// 5. A worker acts only on its own task's rows, reports only what it was
// handed, and a reported outcome stands.
// 6. A task is made only of instructions a worker can pull, and finished
@@ -780,6 +785,14 @@ func (d *TaskDispatch) report(ctx context.Context, eventID int64, apply func(con
}
wrote, err := apply(ctx, tx, taskID, te)
if err != nil {
+ // A refusal the worker can read is passed on as it is: wrapping it
+ // would put this package's name in the middle of the message the
+ // worker is shown.
+ for _, refusal := range []error{ErrInvalidReport, ErrReportConflict, ErrNotDispatchable, ErrHeldByWorker} {
+ if errors.Is(err, refusal) {
+ return Receipt{}, err
+ }
+ }
return Receipt{}, fmt.Errorf("connector: report on event %d: %w", eventID, err)
}
if wrote {
@@ -1173,23 +1186,25 @@ func StateRoot() (string, error) {
// ResolveStateDir is the one place a state directory is accepted: dir must
// be exactly StateRoot/-, and its account must be
-// accountID, compared as numbers. It returns the agent's Person id.
+// accountID, compared as numbers. It returns the directory made absolute —
+// which is the one every caller should go on to use — and the agent's Person
+// id.
//
// The location is part of the check, not only the name. A directory named
// for this account anywhere else — a copy of another account's ledger renamed
// to match — is refused, because the name is what binds a ledger to an
// account and anyone can choose a name.
-func ResolveStateDir(dir, accountID string) (int64, error) {
+func ResolveStateDir(dir, accountID string) (string, int64, error) {
root, err := StateRoot()
if err != nil {
- return 0, err
+ return "", 0, err
}
abs, err := filepath.Abs(dir)
if err != nil {
- return 0, fmt.Errorf("connector: state directory %q: %w", dir, err)
+ return "", 0, fmt.Errorf("connector: state directory %q: %w", dir, err)
}
- refuse := func(why StateDirProblem, account string) (int64, error) {
- return 0, &StateDirError{Dir: abs, Root: root, Account: account, Want: accountID, Why: why}
+ refuse := func(why StateDirProblem, account string) (string, int64, error) {
+ return "", 0, &StateDirError{Dir: abs, Root: root, Account: account, Want: accountID, Why: why}
}
if filepath.Dir(abs) != root {
return refuse(StateDirElsewhere, "")
@@ -1204,7 +1219,7 @@ func ResolveStateDir(dir, accountID string) (int64, error) {
if errGiven != nil || errWant != nil || given == 0 || given != want {
return refuse(StateDirOtherAccount, account)
}
- return agentID, nil
+ return abs, agentID, nil
}
// LedgerFile is the ledger's file name inside the state directory.
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index f2d34502f..8d4aa9417 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -779,6 +779,7 @@ func TestCompleteRefusesMalformedReports(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := f.d.Complete(ctx, 1, c)
require.ErrorIs(t, err, ErrInvalidReport)
+ assert.NotContains(t, err.Error(), "report on event", "what the worker got wrong reaches it without the ledger's own wrapping")
assert.Equal(t, "exposed", f.rowContext(ctx, t, 1).Delivery)
})
}
@@ -832,12 +833,14 @@ func TestResolveStateDirAcceptsOnlyTheCanonicalDirectory(t *testing.T) {
assert.Equal(t, filepath.Join(home, "basecamp", "connect"), root)
canonical := filepath.Join(root, StateDirName("999", adapterAgentID))
- agentID, err := ResolveStateDir(canonical, "999")
+ resolved, agentID, err := ResolveStateDir(canonical, "999")
require.NoError(t, err)
assert.Equal(t, adapterAgentID, agentID)
- agentID, err = ResolveStateDir(canonical+"/", "0999")
- require.NoError(t, err, "accounts compare as numbers, and a trailing slash is the same directory")
+ assert.Equal(t, canonical, resolved)
+ resolved, agentID, err = ResolveStateDir(canonical+"/../"+StateDirName("999", adapterAgentID)+"/", "0999")
+ require.NoError(t, err, "accounts compare as numbers, and a trailing slash or a .. is the same directory")
assert.Equal(t, adapterAgentID, agentID)
+ assert.Equal(t, canonical, resolved, "the directory every caller goes on to use is the one that was checked")
for name, dir := range map[string]string{
"outside the root": filepath.Join(t.TempDir(), StateDirName("999", adapterAgentID)),
@@ -850,7 +853,7 @@ func TestResolveStateDirAcceptsOnlyTheCanonicalDirectory(t *testing.T) {
"above the root": filepath.Join(root, "..", StateDirName("999", adapterAgentID)),
} {
t.Run(name, func(t *testing.T) {
- _, err := ResolveStateDir(dir, "999")
+ _, _, err := ResolveStateDir(dir, "999")
assert.ErrorIs(t, err, ErrNotAStateDir)
var refusal *StateDirError
require.ErrorAs(t, err, &refusal, "the refusal says why in fields, not in a message to be parsed")
diff --git a/internal/connector/strip_mentions_test.go b/internal/connector/strip_mentions_test.go
index 08c8b7176..9b78ee185 100644
--- a/internal/connector/strip_mentions_test.go
+++ b/internal/connector/strip_mentions_test.go
@@ -8,9 +8,9 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)
-// StripMentionsOf is held to the reader admission decides with,
-// basecamp.MentionedPersonIDs, over markup built to make two parsers
-// disagree. Three properties, for every input:
+// StripMentionsOf is held to agreement with the reader admission decides the
+// trigger with, basecamp.MentionedPersonIDs, over markup built to make two
+// parsers disagree. Three properties, for every input:
//
// 1. no mention of the agent survives;
// 2. every other person the reader found is still found, in order;
From 9dbed2a76bdb324a2e2f8e8fd61c78231f5b6244 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 12:52:58 +0200
Subject: [PATCH 16/33] One privacy check per ledger file; the task's own
columns are the database's
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The privacy check opens the file and closes it, and POSIX drops every lock a
process holds on a file when any descriptor for it closes — SQLite's locks
included. A second Ledger on a live file (a status read beside a running
connector, a promote) would have taken the first handle's locks away. The
check now runs once per file per process, and a later Ledger is verified by
stat against what that check established: same file, owner-only, private
directory. A test opens a second Ledger on a live file, reads and writes both
ways, and pins that no second check ran.
Supersession and retirement are final in the database, and neither row is
deleted. Task creation reads everything it needs before it writes anything, so
a refusal leaves the caller's transaction untouched. An unknown task id is not
silently superseded. A state directory names its agent one way. A self-closing
mention stands alone, so no strip can swallow the instruction up to some later
stray closing tag, and the differential test keeps what is outside the removed
spans. The lifecycle table says how an operator's redispatch is made of the
same two writes, and what it cannot do.
---
.../commands/mcp_connect_token_unix_test.go | 4 +
internal/connector/dispatch_lifecycle_test.go | 27 +++
internal/connector/ledger.go | 162 ++++++++++++++++--
internal/connector/ledger_dispatch.go | 92 +++++++---
internal/connector/ledger_dispatch_test.go | 24 ++-
internal/connector/ledger_test.go | 43 +++++
internal/connector/strip_mentions_test.go | 24 ++-
7 files changed, 338 insertions(+), 38 deletions(-)
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 68b78ce56..6a629979c 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -32,6 +32,9 @@ func tokenPipe(t *testing.T, token string) int {
fd, err := syscall.Dup(int(r.Fd()))
require.NoError(t, err)
require.NoError(t, r.Close())
+ // The command closes it once it reads the token; a case that never gets
+ // that far leaves it to this.
+ t.Cleanup(func() { _ = syscall.Close(fd) })
return fd
}
@@ -163,6 +166,7 @@ func heldPipe(t *testing.T, written string) int {
fd, err := syscall.Dup(int(r.Fd()))
require.NoError(t, err)
require.NoError(t, r.Close())
+ t.Cleanup(func() { _ = syscall.Close(fd) })
return fd
}
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index a66b50377..07ed63447 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -64,6 +64,9 @@ func testWithdrawal(t *testing.T) {
defer func() { _ = tx2.Rollback() }()
require.Error(t, f.ledger.withdrawExposure(ctx, tx2, f.grant.ID, 1, StateAdmitted, ""), "once")
require.Error(t, f.ledger.withdrawExposure(ctx, tx2, f.grant.ID, 2, StateAdmitted, ""), "a sibling never exposed has nothing to withdraw")
+ // The database refuses the same, whoever writes.
+ _, err = tx2.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = 'raw' WHERE event_id = 2`)
+ require.Error(t, err)
_, err = tx2.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = 'again' WHERE event_id = 1`)
require.Error(t, err, "once, whoever writes")
_, err = tx2.ExecContext(ctx, `UPDATE task_events SET delivery = 'delivered' WHERE event_id = 1`)
@@ -384,6 +387,30 @@ func testWorkerActions(t *testing.T) {
}
}
+// A task's own columns are the database's too: supersession and retirement
+// are final, and neither row is ever deleted.
+func TestATaskIsSupersededNeverUnsupersededOrDeleted(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+
+ for name, statement := range map[string]string{
+ "un-supersede the task": `UPDATE tasks SET superseded_at = NULL WHERE id = ?`,
+ "delete the task": `DELETE FROM tasks WHERE id = ?`, // its events reference it
+ "un-retire its events": `UPDATE task_events SET retired_at = NULL WHERE task_id = ?`,
+ "delete its events": `DELETE FROM task_events WHERE task_id = ?`,
+ "change the retired stamp": `UPDATE task_events SET retired_at = 'later' WHERE task_id = ?`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := f.ledger.db.ExecContext(ctx, statement, f.grant.ID)
+ require.Error(t, err)
+ })
+ }
+ _, err := f.ledger.Dispatch(ctx, f.grant.Token, adapterAgentID)
+ assert.ErrorIs(t, err, ErrTaskTokenRefused, "the token stays refused")
+ assert.ErrorIs(t, f.ledger.SupersedeTask(ctx, 404), ErrNoSuchTask, "an unknown task is not silently superseded")
+}
+
// testTaskTransitions: live to superseded, once, and a token valid only
// while its task is live.
func testTaskTransitions(t *testing.T) {
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index e732b1e3d..47bf82205 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
+ "sync/atomic"
"time"
"modernc.org/sqlite" // database/sql driver "sqlite", pure Go: no cgo on any of the five release targets.
@@ -70,8 +72,10 @@ const (
// connector makes about a crash rests on the answer to "have I seen this id
// before?" surviving the crash.
type Ledger struct {
- db *sql.DB
- now func() time.Time
+ db *sql.DB
+ // path is the file, absolute: what this process holds open (ErrLedgerInUse).
+ path string
+ now func() time.Time
}
// OpenLedger opens (creating if absent) the ledger at path and brings its
@@ -128,22 +132,31 @@ func openLedger(ctx context.Context, path string, owner bool) (*Ledger, error) {
// query, fragment or an escape, and open some other file.
return nil, fmt.Errorf("connector: ledger path %q contains a character the SQLite URI cannot carry (?, # or %%)", path)
}
- if err := securePath(path, owner); err != nil {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, fmt.Errorf("connector: ledger path %q: %w", path, err)
+ }
+ // The descriptor check runs for the first Ledger on this file and never
+ // while another one is open: its close would drop that one's locks.
+ file := claimLedger(abs)
+ if err := checkLedgerFile(file, path, abs, owner); err != nil {
+ releaseLedger(abs)
return nil, err
}
db, err := sql.Open("sqlite", ledgerDSN(path, owner))
if err != nil {
+ releaseLedger(abs)
return nil, fmt.Errorf("connector: open ledger: %w", err)
}
// One writer. SQLite serializes writers anyway, and a pool merely turns
// that serialization into SQLITE_BUSY under load.
db.SetMaxOpenConns(1)
- l := &Ledger{db: db, now: time.Now}
+ l := &Ledger{db: db, path: abs, now: time.Now}
if owner {
if err := retryBusy(func() error { return l.migrate(ctx) }); err != nil {
- _ = db.Close()
+ _ = l.Close()
return nil, err
}
} else {
@@ -154,11 +167,11 @@ func openLedger(ctx context.Context, path string, owner bool) (*Ledger, error) {
return err
})
if err != nil {
- _ = db.Close()
+ _ = l.Close()
return nil, fmt.Errorf("connector: read ledger schema: %w", err)
}
if version != len(migrations) {
- _ = db.Close()
+ _ = l.Close()
return nil, fmt.Errorf("connector: ledger at schema %d, this basecamp writes %d: %w", version, len(migrations), ErrLedgerSchema)
}
}
@@ -167,7 +180,7 @@ func openLedger(ctx context.Context, path string, owner bool) (*Ledger, error) {
// tightening them too costs nothing.
for _, sidecar := range []string{path + "-wal", path + "-shm"} {
if err := os.Chmod(sidecar, 0o600); err != nil && !os.IsNotExist(err) {
- _ = db.Close()
+ _ = l.Close()
return nil, fmt.Errorf("connector: secure ledger sidecar: %w", err)
}
}
@@ -249,8 +262,117 @@ func securePath(path string, create bool) error {
return nil
}
-// Close releases the ledger's handle.
-func (l *Ledger) Close() error { return l.db.Close() }
+// Close releases the ledger's handle and lets this process open the file
+// again.
+func (l *Ledger) Close() error {
+ releaseLedger(l.path)
+ return l.db.Close()
+}
+
+// Opening one ledger file more than once in a process, safely.
+//
+// The privacy check opens the file and closes it, and POSIX drops every lock
+// a process holds on a file when any descriptor for it is closed — including
+// the locks SQLite is holding on another connection. So the check runs
+// exactly once per file per process, while nothing else has it open. A later
+// Ledger on the same file (a status read beside a running connector, a
+// promote) is verified instead against what that check established: the same
+// file, still this user's own, still owner-only, in a directory that is
+// still 0700. Stat never opens anything, so it takes no locks away.
+//
+// ErrLedgerNotTheSameFile is a second open of a path that no longer names the
+// file the check passed.
+var ErrLedgerNotTheSameFile = errors.New("the ledger path no longer names the file this process checked")
+
+var openLedgers struct {
+ sync.Mutex
+ files map[string]*openLedgerFile
+}
+
+type openLedgerFile struct {
+ refs int
+ // mu serializes the check itself, so opens that race each other on a
+ // fresh file do not verify against a check that has not run yet.
+ mu sync.Mutex
+ // info is the file as the descriptor check saw it, nil until it has run.
+ info os.FileInfo
+}
+
+// securePathRuns counts the checks that open the file. A test pins that a
+// second Ledger on a live file runs none.
+var securePathRuns atomic.Int64
+
+// claimLedger records this process opening path and returns that file's
+// entry, whose lock the caller takes to check it.
+func claimLedger(path string) *openLedgerFile {
+ openLedgers.Lock()
+ defer openLedgers.Unlock()
+ if openLedgers.files == nil {
+ openLedgers.files = map[string]*openLedgerFile{}
+ }
+ file := openLedgers.files[path]
+ if file == nil {
+ file = &openLedgerFile{}
+ openLedgers.files[path] = file
+ }
+ file.refs++
+ return file
+}
+
+func releaseLedger(path string) {
+ openLedgers.Lock()
+ defer openLedgers.Unlock()
+ file := openLedgers.files[path]
+ if file == nil {
+ return
+ }
+ if file.refs--; file.refs <= 0 {
+ delete(openLedgers.files, path)
+ }
+}
+
+// checkLedgerFile runs the descriptor check once per file, and holds every
+// later open against what it established.
+func checkLedgerFile(file *openLedgerFile, path, abs string, owner bool) error {
+ file.mu.Lock()
+ defer file.mu.Unlock()
+ if file.info != nil {
+ return verifySameFile(abs, file.info)
+ }
+ securePathRuns.Add(1)
+ if err := securePath(path, owner); err != nil {
+ return err
+ }
+ info, err := os.Lstat(abs)
+ if err != nil {
+ return fmt.Errorf("connector: inspect the ledger: %w", err)
+ }
+ file.info = info
+ return nil
+}
+
+// verifySameFile holds a second open to what the first one's check
+// established, without opening anything.
+func verifySameFile(path string, checked os.FileInfo) error {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return fmt.Errorf("connector: secure the ledger: %w", err)
+ }
+ if !info.Mode().IsRegular() || !os.SameFile(info, checked) {
+ return fmt.Errorf("connector: secure the ledger: %s: %w", path, ErrLedgerNotTheSameFile)
+ }
+ if perm := info.Mode().Perm(); perm&0o077 != 0 {
+ return fmt.Errorf("connector: secure the ledger: %s can be read by other users (mode %04o)", path, perm)
+ }
+ dir, err := os.Lstat(filepath.Dir(path))
+ if err != nil {
+ return fmt.Errorf("connector: inspect ledger directory: %w", err)
+ }
+ if perm := dir.Mode().Perm(); perm&0o077 != 0 {
+ return fmt.Errorf("connector: ledger directory %s is readable by other users (mode %04o); it must be 0700", filepath.Dir(path), perm)
+ }
+ return nil
+}
// migrations are applied in order, each exactly once. A migration is never
// edited after it ships: the ledger outlives the binary that created it.
@@ -433,6 +555,26 @@ CREATE TABLE task_events (
CREATE UNIQUE INDEX task_events_one_live_task ON task_events (event_id) WHERE retired_at IS NULL;
CREATE INDEX task_events_event ON task_events (event_id, delivery);
+CREATE TRIGGER tasks_supersession_is_final
+BEFORE UPDATE OF superseded_at ON tasks
+WHEN OLD.superseded_at IS NOT NULL AND NEW.superseded_at IS NOT OLD.superseded_at
+BEGIN
+ SELECT RAISE(ABORT, 'a superseded task stays superseded');
+END;
+
+CREATE TRIGGER task_events_retirement_is_final
+BEFORE UPDATE OF retired_at ON task_events
+WHEN OLD.retired_at IS NOT NULL AND NEW.retired_at IS NOT OLD.retired_at
+BEGIN
+ SELECT RAISE(ABORT, 'a retired task event stays retired');
+END;
+
+CREATE TRIGGER task_events_are_not_deleted
+BEFORE DELETE ON task_events
+BEGIN
+ SELECT RAISE(ABORT, 'a task event is retired, never deleted');
+END;
+
CREATE TRIGGER task_events_withdrawal_is_for_a_failed_spawn
BEFORE UPDATE OF withdrawn_at ON task_events
WHEN NEW.withdrawn_at IS NOT OLD.withdrawn_at AND (
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index a57fc4ee7..14410b446 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -74,7 +74,16 @@ import (
//
// live created by the dispatcher (CreateTask); its token is valid
// superseded by the dispatcher or an operator's redispatch (SupersedeTask);
-// its token is refused; terminal
+// its token is refused; terminal, and the row is never deleted
+//
+// An operator's redispatch is the dispatcher's own two writes in one
+// transaction: supersedeTask on the live task — which refuses its token from
+// then on, retires its rows, and returns what no worker was handed to
+// admitted — and createTask for the events being run again. It can therefore
+// redispatch an admitted, queued or dispatched record, which covers held work
+// waiting for its outcome and the spawn-failure retry. A completed or
+// discarded record it cannot: those are terminal here, so redispatching one
+// is a decision this ledger does not carry (plan step 21 owns it).
//
// # Delivery (task_events.delivery), per event on a task
//
@@ -180,6 +189,8 @@ var (
// work a worker was handed that is not settled yet. A conversation has
// one task at a time.
ErrConversationBusy = errors.New("the event's conversation already has a task")
+ // ErrNoSuchTask is a task id the ledger does not hold.
+ ErrNoSuchTask = errors.New("no such task")
// ErrEventOnLiveTask is an event a live task already carries. Handing it
// to a second task would give two workers one instruction.
ErrEventOnLiveTask = errors.New("the event is already on a live task")
@@ -242,14 +253,9 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
}
token := base64.RawURLEncoding.EncodeToString(raw)
- res, err := tx.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at) VALUES (?, ?)`, tokenHash(token), l.timestamp())
- if err != nil {
- return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
- }
- taskID, err := res.LastInsertId()
- if err != nil {
- return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
- }
+ // Read first, write after: a refusal leaves the caller's transaction as
+ // it found it, whatever the caller then does with it.
+ guards := make([]string, 0, len(eventIDs))
for _, id := range eventIDs {
var acknowledge, hasInstruction int
switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction); {
@@ -258,6 +264,15 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
case err != nil:
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
+ var onLive bool
+ if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM task_events WHERE event_id = ? AND retired_at IS NULL)`, id).Scan(&onLive); err != nil {
+ return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
+ }
+ if onLive {
+ // The unique index refuses it too, whoever writes; this is the
+ // same refusal with the event named.
+ return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrEventOnLiveTask)
+ }
if hasInstruction == 0 {
// A task a worker could pull nothing from would read as a task
// with nothing left to do. A record without its instruction
@@ -268,18 +283,13 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
if acknowledge != 0 {
guard = "armed"
}
- if _, err := tx.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id, guard) VALUES (?, ?, ?)`, taskID, id, guard); err != nil {
- if isConstraint(err) {
- return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrEventOnLiveTask)
- }
- return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
- }
+ guards = append(guards, guard)
}
// One task per conversation: every dispatched record on the events'
- // conversations must be among the events this task takes. Checked after
- // every event is on the task — so an event already on a live task is told
- // as that — and before any of them moves, so the records this call
+ // conversations must be among the events this task takes. Checked before
+ // anything is written, so a refusal leaves the caller's transaction
+ // untouched, and before any record moves, so the records this call
// dispatches never count.
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(eventIDs)), ", ")
args := make([]any, 0, len(eventIDs)*2)
@@ -303,6 +313,22 @@ LIMIT 1`, args...).Scan(&busy); {
return TaskGrant{}, fmt.Errorf("connector: read conversations: %w", err)
}
+ res, err := tx.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at) VALUES (?, ?)`, tokenHash(token), l.timestamp())
+ if err != nil {
+ return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
+ }
+ taskID, err := res.LastInsertId()
+ if err != nil {
+ return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
+ }
+ for i, id := range eventIDs {
+ if _, err := tx.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id, guard) VALUES (?, ?, ?)`, taskID, id, guards[i]); err != nil {
+ if isConstraint(err) {
+ return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrEventOnLiveTask)
+ }
+ return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
+ }
+ }
for _, id := range eventIDs {
// Admitted or queued work joins a task; a dispatched record whose
// task was superseded joins its replacement.
@@ -341,6 +367,13 @@ func (l *Ledger) SupersedeTask(ctx context.Context, taskID int64) error {
// supersedeTask is SupersedeTask inside the caller's transaction, so a
// redispatch can retire the old task and create the new one in one commit.
func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) error {
+ var exists bool
+ if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM tasks WHERE id = ?)`, taskID).Scan(&exists); err != nil {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
+ }
+ if !exists {
+ return fmt.Errorf("connector: supersede task %d: %w", taskID, ErrNoSuchTask)
+ }
rows, err := tx.QueryContext(ctx, `SELECT event_id FROM task_events WHERE task_id = ? AND retired_at IS NULL AND delivery = 'admitted'`, taskID)
if err != nil {
return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
@@ -572,7 +605,7 @@ func (d *TaskDispatch) get(ctx context.Context, eventID int64) (Instruction, boo
if eventID == 0 {
err := tx.QueryRowContext(ctx, `
SELECT te.event_id FROM task_events te JOIN events e ON e.id = te.event_id
-WHERE te.task_id = ? AND te.delivery IN ('admitted', 'exposed') AND `+servableSQL+`
+WHERE te.task_id = ? AND te.retired_at IS NULL AND te.delivery IN ('admitted', 'exposed') AND `+servableSQL+`
ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return Instruction{}, false, nil
@@ -842,7 +875,7 @@ func loadTaskEvent(ctx context.Context, tx *sql.Tx, taskID, eventID int64) (task
)
err := tx.QueryRowContext(ctx, `
SELECT delivery, guard, ack_id, outcome, links, reply_id, pulled_at IS NOT NULL
-FROM task_events WHERE task_id = ? AND event_id = ?`, taskID, eventID).Scan(&delivery, &te.guard, &te.ackID, &te.outcome, &te.links, &te.replyID, &te.pulled)
+FROM task_events WHERE task_id = ? AND event_id = ? AND retired_at IS NULL`, taskID, eventID).Scan(&delivery, &te.guard, &te.ackID, &te.outcome, &te.links, &te.replyID, &te.pulled)
if errors.Is(err, sql.ErrNoRows) {
return te, fmt.Errorf("connector: event %d: %w", eventID, ErrNotOnTask)
}
@@ -914,8 +947,10 @@ func sameID(stored sql.NullInt64, given *int64) bool {
// markup, not by review.
//
// A mention element runs from its start tag to the first end tag of the same
-// name, unless another attachment starts first or none closes, in which case
-// the start tag stands alone. What it leaves behind is a space, not nothing:
+// name, unless it closes itself, another attachment starts first, or none
+// closes, in which case the start tag stands alone. So no removal can swallow
+// the instruction between a self-closing mention and some later stray closing
+// tag. What it leaves behind is a space, not nothing:
// closing the gap could join a "<" before the element to the text after it
// into a tag that swallows what follows — someone else's mention included —
// and a space can never begin one.
@@ -956,7 +991,14 @@ func stripOnce(text string, personID int64) (string, [][2]int) {
if id, isPerson := basecamp.PersonIDFromSGID(t.sgid); isPerson && id == personID {
out.WriteString(text[pos:t.start])
out.WriteString(strippedMention)
- pos = mentionEnd(text, t.end)
+ if strings.HasSuffix(text[t.start:t.end], "/>") {
+ // A self-closing tag is the whole element: what follows
+ // is not its content, and a later stray closing tag is
+ // not its end.
+ pos = t.end
+ } else {
+ pos = mentionEnd(text, t.end)
+ }
removed = append(removed, [2]int{t.start, pos})
continue
}
@@ -1211,7 +1253,9 @@ func ResolveStateDir(dir, accountID string) (string, int64, error) {
}
account, agent, ok := strings.Cut(filepath.Base(abs), "-")
agentID, err := strconv.ParseInt(agent, 10, 64)
- if !ok || err != nil || agentID <= 0 {
+ if !ok || err != nil || agentID <= 0 || agent != strconv.FormatInt(agentID, 10) {
+ // The agent is spelled one way, so one directory answers to one name:
+ // "+52007412" and "052007412" are other directories, not this one.
return refuse(StateDirMisnamed, account)
}
given, errGiven := strconv.ParseUint(account, 10, 64)
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 8d4aa9417..7801f96d5 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -570,19 +570,30 @@ func TestCompletedWorkIsServedOnlyToItsWorker(t *testing.T) {
}
func TestConcurrentLaunchesOfOneEventMakeOneTask(t *testing.T) {
- ledger := newTestLedger(t)
+ path := filepath.Join(t.TempDir(), "state", "connector.db")
+ ledger, err := OpenLedger(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ledger.Close() })
ctx := context.Background()
seenRecord(t, ledger, 1)
- _, err := ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
+ _, err = ledger.Admission().Commit(ctx, admittedVerdict(1, 0, "recording:9"))
require.NoError(t, err)
+ // Each racer has its own handle on the file, so SQLite sees the race
+ // rather than database/sql's one connection serializing it.
const racers = 8
errs := make([]error, racers)
done := make(chan struct{})
for i := range racers {
go func() {
defer func() { done <- struct{}{} }()
- _, errs[i] = ledger.CreateTask(ctx, []int64{1})
+ handle, err := OpenExistingLedger(ctx, path)
+ if err != nil {
+ errs[i] = err
+ return
+ }
+ defer handle.Close()
+ _, errs[i] = handle.CreateTask(ctx, []int64{1})
}()
}
for range racers {
@@ -811,6 +822,8 @@ func TestStripMentionsOf(t *testing.T) {
"uppercase": {"a" + strings.ToUpper(agent[:14]) + agent[14:] + "b", "a b"},
"the first sgid is the one": {strings.Replace(agent, "
thanks
tail",
+ " " + " please deploy
thanks
tail"},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, StripMentionsOf(tc.in, adapterAgentID))
@@ -849,6 +862,8 @@ func TestResolveStateDirAcceptsOnlyTheCanonicalDirectory(t *testing.T) {
"no agent": filepath.Join(root, "999-"),
"no account": filepath.Join(root, "-52007412"),
"not a number": filepath.Join(root, "999-abc"),
+ "a signed agent": filepath.Join(root, "999-+52007412"),
+ "a padded agent": filepath.Join(root, "999-052007412"),
"the root itself": root,
"above the root": filepath.Join(root, "..", StateDirName("999", adapterAgentID)),
} {
@@ -1005,6 +1020,9 @@ func TestAWithdrawalIsRefusedWhenAWorkerCouldHaveTheInstruction(t *testing.T) {
require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
_, err = f.ledger.createTask(ctx, tx, []int64{1, 2})
require.NoError(t, err)
+ // The database refuses it whoever writes, and on an untouched row.
+ _, err = tx.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = 'raw' WHERE task_id = ? AND event_id = 1`, f.grant.ID)
+ require.Error(t, err)
require.Error(t, f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, ""), "create before withdraw is the wrong order")
})
diff --git a/internal/connector/ledger_test.go b/internal/connector/ledger_test.go
index 0c94ceb8f..913ff657a 100644
--- a/internal/connector/ledger_test.go
+++ b/internal/connector/ledger_test.go
@@ -238,3 +238,46 @@ func TestLedgerRefusesALooseAncestor(t *testing.T) {
require.Error(t, err)
assert.ErrorIs(t, err, setup.ErrNotPrivate)
}
+
+// A second Ledger on a file this process already has open — a status read
+// beside a running connector, a promote — must not run the check that opens
+// the file: POSIX drops every lock this process holds on a file when any
+// descriptor for it is closed, SQLite's included, and the first handle would
+// go on believing it still held them.
+func TestASecondLedgerOnALiveFileNeitherOpensNorDisturbsIt(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "connector.db")
+ first, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer first.Close()
+ ctx := context.Background()
+ _, err = first.RecordSeen(ctx, testEvent(1), LanePoll)
+ require.NoError(t, err)
+ checksBefore := securePathRuns.Load()
+
+ second, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer second.Close()
+
+ assert.Equal(t, checksBefore, securePathRuns.Load(), "the check that opens the file did not run again")
+
+ // Both handles read and write, in both orders, with the other's locks
+ // still in place.
+ _, err = second.RecordSeen(ctx, testEvent(2), LanePoll)
+ require.NoError(t, err)
+ record, ok, err := first.Get(ctx, 2)
+ require.NoError(t, err)
+ require.True(t, ok, "the first handle reads what the second wrote")
+ assert.Equal(t, StateSeen, record.State)
+ require.NoError(t, first.SetState(ctx, 1, StateDiscarded, "untrusted_author"))
+ record, ok, err = second.Get(ctx, 1)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, StateDiscarded, record.State)
+
+ // The file has to be the one that was checked.
+ require.NoError(t, second.Close())
+ require.NoError(t, os.Rename(path, path+".moved"))
+ require.NoError(t, os.WriteFile(path, nil, 0o600))
+ _, err = OpenLedger(path)
+ assert.ErrorIs(t, err, ErrLedgerNotTheSameFile)
+}
diff --git a/internal/connector/strip_mentions_test.go b/internal/connector/strip_mentions_test.go
index 9b78ee185..67bd87992 100644
--- a/internal/connector/strip_mentions_test.go
+++ b/internal/connector/strip_mentions_test.go
@@ -14,7 +14,9 @@ import (
//
// 1. no mention of the agent survives;
// 2. every other person the reader found is still found, in order;
-// 3. text with no mention of the agent comes back unchanged.
+// 3. text with no mention of the agent comes back unchanged;
+// 4. everything outside the removed spans is kept, byte for byte — a
+// removal takes a mention, never the instruction around it.
//
// The pieces are combined in threes, so each hostile form meets each other in
// both orders and inside or around an element.
@@ -85,6 +87,9 @@ func checkStrip(t *testing.T, input string) {
}
}
+ if kept := keptOutsideRemovals(input, adapterAgentID); kept != "" && !strings.Contains(got, kept) && !isEscapeFallback(input, got) {
+ t.Fatalf("text outside the removed mentions was lost\n in: %q\nout: %q\nkept: %q", input, got, kept)
+ }
if slices.Contains(after, adapterAgentID) {
t.Fatalf("the agent's mention survived\n in: %q\nout: %q", input, got)
}
@@ -156,3 +161,20 @@ func restoreSpan(input string, removed [][2]int, keep [2]int) string {
b.WriteString(input[pos:])
return b.String()
}
+
+// keptOutsideRemovals is the longest run of text the strip did not remove,
+// which the output must still contain.
+func keptOutsideRemovals(input string, personID int64) string {
+ _, removed := stripOnce(input, personID)
+ longest, pos := "", 0
+ for _, span := range removed {
+ if between := input[pos:span[0]]; len(between) > len(longest) {
+ longest = between
+ }
+ pos = span[1]
+ }
+ if tail := input[pos:]; len(tail) > len(longest) {
+ longest = tail
+ }
+ return longest
+}
From 7e2180273d1a3c6d18988369371c583202a007cd Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 13:10:23 +0200
Subject: [PATCH 17/33] Know the ledger file by what it is, close it once,
refuse before writing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four things review found in the new code. The one-check-per-file registry was
keyed by the path as spelled, so a hardlink or a symlinked route ran the check
again — the open and close that drops the live handle's locks. It is now keyed
by the file itself, and a second name for a file this process has open is
refused before anything opens it, because SQLite names its write-ahead log
after the path and one file under two names is two logs.
Close releases the file once, so a defensive second Close cannot take the
entry from another handle. Task creation reads the record's state in the same
pass as the rest, so a refusal writes nothing at all. And a mention element
ends at a closing tag that closes something it opened: a stray close after an
unclosed mention is not its end, so no strip swallows the instruction. The
differential test judges each removed span by its own text, so a span that ran
too far cannot hide it.
---
internal/connector/ledger.go | 59 +++++++++++++++------
internal/connector/ledger_dispatch.go | 51 +++++++++++++++---
internal/connector/ledger_dispatch_test.go | 30 +++++++++++
internal/connector/ledger_test.go | 48 +++++++++++++++++
internal/connector/strip_mentions_test.go | 60 ++++++++++++++++------
5 files changed, 207 insertions(+), 41 deletions(-)
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 47bf82205..957b24101 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -73,9 +73,11 @@ const (
// before?" surviving the crash.
type Ledger struct {
db *sql.DB
- // path is the file, absolute: what this process holds open (ErrLedgerInUse).
- path string
- now func() time.Time
+ // file is this process's entry for the ledger file, shared with every
+ // other Ledger open on it; closed releases it once.
+ file *openLedgerFile
+ closed sync.Once
+ now func() time.Time
}
// OpenLedger opens (creating if absent) the ledger at path and brings its
@@ -139,21 +141,25 @@ func openLedger(ctx context.Context, path string, owner bool) (*Ledger, error) {
// The descriptor check runs for the first Ledger on this file and never
// while another one is open: its close would drop that one's locks.
file := claimLedger(abs)
+ if file.key != abs {
+ releaseLedger(file)
+ return nil, fmt.Errorf("connector: %s and %s are one file: %w", abs, file.key, ErrLedgerUnderAnotherName)
+ }
if err := checkLedgerFile(file, path, abs, owner); err != nil {
- releaseLedger(abs)
+ releaseLedger(file)
return nil, err
}
db, err := sql.Open("sqlite", ledgerDSN(path, owner))
if err != nil {
- releaseLedger(abs)
+ releaseLedger(file)
return nil, fmt.Errorf("connector: open ledger: %w", err)
}
// One writer. SQLite serializes writers anyway, and a pool merely turns
// that serialization into SQLITE_BUSY under load.
db.SetMaxOpenConns(1)
- l := &Ledger{db: db, path: abs, now: time.Now}
+ l := &Ledger{db: db, file: file, now: time.Now}
if owner {
if err := retryBusy(func() error { return l.migrate(ctx) }); err != nil {
_ = l.Close()
@@ -262,10 +268,11 @@ func securePath(path string, create bool) error {
return nil
}
-// Close releases the ledger's handle and lets this process open the file
-// again.
+// Close releases the ledger's handle. A second Close is harmless: the file is
+// released once, so a defensive extra call cannot take the entry away from
+// another Ledger still holding the same file.
func (l *Ledger) Close() error {
- releaseLedger(l.path)
+ l.closed.Do(func() { releaseLedger(l.file) })
return l.db.Close()
}
@@ -284,12 +291,22 @@ func (l *Ledger) Close() error {
// file the check passed.
var ErrLedgerNotTheSameFile = errors.New("the ledger path no longer names the file this process checked")
+// ErrLedgerUnderAnotherName is an open of a file this process already has
+// open under a different path — a hardlink, or a route through a symlink.
+// SQLite names its write-ahead log and shared-memory files after the path it
+// was given, so one file opened under two names is two different logs for one
+// database. It is refused here, before the check that would open the file and
+// drop the live handle's locks.
+var ErrLedgerUnderAnotherName = errors.New("this process already has this ledger open under another name")
+
var openLedgers struct {
sync.Mutex
files map[string]*openLedgerFile
}
type openLedgerFile struct {
+ // key is this entry's key in the map, so it can be released by entry.
+ key string
refs int
// mu serializes the check itself, so opens that race each other on a
// fresh file do not verify against a check that has not run yet.
@@ -304,6 +321,10 @@ var securePathRuns atomic.Int64
// claimLedger records this process opening path and returns that file's
// entry, whose lock the caller takes to check it.
+//
+// The entry is found by what the path names, not by how it is spelled: a
+// hardlink, a symlink or another route to the same file must meet the same
+// entry, because the check this guards opens and closes the file itself.
func claimLedger(path string) *openLedgerFile {
openLedgers.Lock()
defer openLedgers.Unlock()
@@ -312,22 +333,28 @@ func claimLedger(path string) *openLedgerFile {
}
file := openLedgers.files[path]
if file == nil {
- file = &openLedgerFile{}
+ if info, err := os.Lstat(path); err == nil {
+ for _, open := range openLedgers.files {
+ if open.info != nil && os.SameFile(open.info, info) {
+ file = open
+ break
+ }
+ }
+ }
+ }
+ if file == nil {
+ file = &openLedgerFile{key: path}
openLedgers.files[path] = file
}
file.refs++
return file
}
-func releaseLedger(path string) {
+func releaseLedger(file *openLedgerFile) {
openLedgers.Lock()
defer openLedgers.Unlock()
- file := openLedgers.files[path]
- if file == nil {
- return
- }
if file.refs--; file.refs <= 0 {
- delete(openLedgers.files, path)
+ delete(openLedgers.files, file.key)
}
}
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 14410b446..7e041b7f2 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -257,13 +257,19 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
// it found it, whatever the caller then does with it.
guards := make([]string, 0, len(eventIDs))
for _, id := range eventIDs {
- var acknowledge, hasInstruction int
- switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction); {
+ var (
+ acknowledge, hasInstruction int
+ state string
+ )
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL, state FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction, &state); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
}
+ if s := RecordState(state); s != StateAdmitted && s != StateQueued && s != StateDispatched {
+ return TaskGrant{}, fmt.Errorf("connector: task event %d is %s; only admitted, queued or redispatched work joins a task", id, state)
+ }
var onLive bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM task_events WHERE event_id = ? AND retired_at IS NULL)`, id).Scan(&onLive); err != nil {
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, err)
@@ -332,14 +338,14 @@ LIMIT 1`, args...).Scan(&busy); {
for _, id := range eventIDs {
// Admitted or queued work joins a task; a dispatched record whose
// task was superseded joins its replacement.
+ // The pre-pass read this state; the move is what makes it so under a
+ // concurrent writer, and refuses if it changed underneath.
moved, err := l.move(ctx, tx, transition{id: id, state: StateDispatched, from: []RecordState{StateAdmitted, StateQueued, StateDispatched}})
if err != nil {
return TaskGrant{}, err
}
if !moved {
- var state string
- _ = tx.QueryRowContext(ctx, `SELECT state FROM events WHERE id = ?`, id).Scan(&state)
- return TaskGrant{}, fmt.Errorf("connector: task event %d is %s; only admitted, queued or redispatched work joins a task", id, state)
+ return TaskGrant{}, fmt.Errorf("connector: task event %d changed state while its task was being written", id)
}
}
return TaskGrant{ID: taskID, Token: token}, nil
@@ -1011,24 +1017,53 @@ func stripOnce(text string, personID int64) (string, [][2]int) {
}
// mentionEnd is where the mention whose start tag ends at from ends: after the
-// first , or at from when another attachment starts first or
-// none closes.
+// first , or at from when another attachment starts first,
+// some other element closes first, or none closes.
func mentionEnd(text string, from int) int {
+ var open []string
for at := from; ; {
t, ok := nextMarkup(text, at)
if !ok {
return from
}
- if strings.EqualFold(t.name, "bc-attachment") {
+ switch {
+ case strings.EqualFold(t.name, "bc-attachment"):
if t.isEnd {
return t.end
}
+ // Another attachment starts: this one was never closed.
return from
+ case t.isEnd:
+ // An end tag for something opened inside the mention — a
+ // mention's own figure closes its parts — is part of it. One that
+ // closes nothing opened here belongs to an element around the
+ // mention, so the closing tag further on is not this mention's:
+ // the start tag stands alone rather than swallowing what follows.
+ depth := len(open) - 1
+ for depth >= 0 && !strings.EqualFold(open[depth], t.name) {
+ depth--
+ }
+ if depth < 0 {
+ return from
+ }
+ open = open[:depth]
+ case !isVoidElement(t.name):
+ open = append(open, t.name)
}
at = t.end
}
}
+// isVoidElement reports the elements of Basecamp's rich text that have no end
+// tag, so an unclosed one of them does not look like something still open.
+func isVoidElement(name string) bool {
+ switch strings.ToLower(name) {
+ case "br", "hr", "img", "source", "input", "meta", "link":
+ return true
+ }
+ return false
+}
+
// markup is one start or end tag the walk found: where it starts and ends,
// its name, whether it is an end tag, and a start tag's first sgid, decoded.
type markup struct {
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 7801f96d5..33338dd43 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -824,6 +824,8 @@ func TestStripMentionsOf(t *testing.T) {
"a stray < before it": {"<" + agent + "hi " + other, "< hi " + other},
"self-closing, then a stray close": {selfClosing + " please deploy
thanks
tail",
" " + " please deploy
thanks
tail"},
+ "unclosed, then a stray close": {unclosed + " please deploy
thanks
tail",
+ " " + " please deploy
thanks
tail"},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, StripMentionsOf(tc.in, adapterAgentID))
@@ -1082,3 +1084,31 @@ WHERE id = ?`, id)
require.NoError(t, err)
return grant
}
+
+// createTask writes nothing when it refuses: the caller's transaction is as
+// it found it, whatever the caller does with it next.
+func TestCreateTaskWritesNothingWhenItRefuses(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ seenRecord(t, f.ledger, 3)
+ _, err := f.ledger.Admission().Commit(ctx, admittedVerdict(3, 0, "recording:3"))
+ require.NoError(t, err)
+ dispatchForTest(t, f.ledger, 3)
+ require.NoError(t, f.ledger.SetState(ctx, 3, StateCompleted, ""), "settled, and its snapshot is still there")
+
+ var tasksBefore, rowsBefore int
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks`).Scan(&tasksBefore))
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_events`).Scan(&rowsBefore))
+
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ _, err = f.ledger.createTask(ctx, tx, []int64{3})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "completed")
+ var tasksAfter, rowsAfter int
+ require.NoError(t, tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks`).Scan(&tasksAfter))
+ require.NoError(t, tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_events`).Scan(&rowsAfter))
+ require.NoError(t, tx.Rollback())
+ assert.Equal(t, tasksBefore, tasksAfter, "no task row")
+ assert.Equal(t, rowsBefore, rowsAfter, "no task event row")
+}
diff --git a/internal/connector/ledger_test.go b/internal/connector/ledger_test.go
index 913ff657a..c770af68f 100644
--- a/internal/connector/ledger_test.go
+++ b/internal/connector/ledger_test.go
@@ -281,3 +281,51 @@ func TestASecondLedgerOnALiveFileNeitherOpensNorDisturbsIt(t *testing.T) {
_, err = OpenLedger(path)
assert.ErrorIs(t, err, ErrLedgerNotTheSameFile)
}
+
+// The one-check-per-file rule is about the file, not the name: a hardlink or
+// another route to a file this process has open is recognized as that file,
+// before the check that would open it.
+func TestASecondNameForALiveLedgerIsRefusedWithoutOpeningIt(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ require.NoError(t, os.MkdirAll(dir, 0o700))
+ path := filepath.Join(dir, "connector.db")
+ first, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer first.Close()
+ checks := securePathRuns.Load()
+
+ link := filepath.Join(dir, "hard.db")
+ require.NoError(t, os.Link(path, link))
+ _, err = OpenExistingLedger(context.Background(), link)
+
+ // Refused, because SQLite names its write-ahead log after the path and
+ // one file under two names is two logs — and refused before the check
+ // that opens the file, which would have dropped the live handle's locks.
+ require.ErrorIs(t, err, ErrLedgerUnderAnotherName)
+ assert.Equal(t, checks, securePathRuns.Load())
+ _, err = first.RecordSeen(context.Background(), testEvent(1), LanePoll)
+ require.NoError(t, err, "the live handle is untouched")
+ _, ok, err := first.Get(context.Background(), 1)
+ require.NoError(t, err)
+ assert.True(t, ok)
+}
+
+// Close releases the file once: a defensive second Close must not take the
+// entry away from another Ledger still holding it.
+func TestClosingALedgerTwiceReleasesItOnce(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "connector.db")
+ first, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer first.Close()
+ second, err := OpenLedger(path)
+ require.NoError(t, err)
+ require.NoError(t, second.Close())
+ require.NoError(t, second.Close())
+ checks := securePathRuns.Load()
+
+ third, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer third.Close()
+
+ assert.Equal(t, checks, securePathRuns.Load(), "the first handle still holds the file")
+}
diff --git a/internal/connector/strip_mentions_test.go b/internal/connector/strip_mentions_test.go
index 67bd87992..463d01ca9 100644
--- a/internal/connector/strip_mentions_test.go
+++ b/internal/connector/strip_mentions_test.go
@@ -15,8 +15,11 @@ import (
// 1. no mention of the agent survives;
// 2. every other person the reader found is still found, in order;
// 3. text with no mention of the agent comes back unchanged;
-// 4. everything outside the removed spans is kept, byte for byte — a
-// removal takes a mention, never the instruction around it.
+// 4. every removed span is a mention element and nothing more — it closes
+// nothing it did not open, so a removal takes a mention and never the
+// instruction around it. This is checked against the span's own text
+// rather than against what the function meant to remove, so a span that
+// is too long cannot hide itself.
//
// The pieces are combined in threes, so each hostile form meets each other in
// both orders and inside or around an element.
@@ -87,8 +90,11 @@ func checkStrip(t *testing.T, input string) {
}
}
- if kept := keptOutsideRemovals(input, adapterAgentID); kept != "" && !strings.Contains(got, kept) && !isEscapeFallback(input, got) {
- t.Fatalf("text outside the removed mentions was lost\n in: %q\nout: %q\nkept: %q", input, got, kept)
+ _, removed := stripOnce(input, adapterAgentID)
+ for _, span := range removed {
+ if element := input[span[0]:span[1]]; !isOneMentionElement(element) {
+ t.Fatalf("a removal took more than one mention element: %q\n in: %q\nout: %q", element, input, got)
+ }
}
if slices.Contains(after, adapterAgentID) {
t.Fatalf("the agent's mention survived\n in: %q\nout: %q", input, got)
@@ -162,19 +168,39 @@ func restoreSpan(input string, removed [][2]int, keep [2]int) string {
return b.String()
}
-// keptOutsideRemovals is the longest run of text the strip did not remove,
-// which the output must still contain.
-func keptOutsideRemovals(input string, personID int64) string {
- _, removed := stripOnce(input, personID)
- longest, pos := "", 0
- for _, span := range removed {
- if between := input[pos:span[0]]; len(between) > len(longest) {
- longest = between
- }
- pos = span[1]
+// isOneMentionElement reports a removed span that is one bc-attachment
+// element: it opens with that tag, and every end tag inside it either closes
+// something the span itself opened or is the element's own closing tag. A span that
+// ran past its element and swallowed a "" from the text around it fails
+// here.
+func isOneMentionElement(element string) bool {
+ first, ok := nextMarkup(element, 0)
+ if !ok || first.isEnd || !strings.EqualFold(first.name, "bc-attachment") || first.start != 0 {
+ return false
}
- if tail := input[pos:]; len(tail) > len(longest) {
- longest = tail
+ var open []string
+ for at := first.end; at < len(element); {
+ t, ok := nextMarkup(element, at)
+ if !ok {
+ break
+ }
+ switch {
+ case t.isEnd && strings.EqualFold(t.name, "bc-attachment"):
+ // Its own closing tag ends it, whatever it left open inside.
+ return t.end == len(element)
+ case t.isEnd:
+ depth := len(open) - 1
+ for depth >= 0 && !strings.EqualFold(open[depth], t.name) {
+ depth--
+ }
+ if depth < 0 {
+ return false // it closed something it did not open
+ }
+ open = open[:depth]
+ case !isVoidElement(t.name):
+ open = append(open, t.name)
+ }
+ at = t.end
}
- return longest
+ return true // the start tag stands alone
}
From c11e5547083d9f2e810e4d3adee21ccf408ab811 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 13:23:39 +0200
Subject: [PATCH 18/33] Claim the ledger file by identity, release it after the
database closes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two windows in the mechanism that exists to keep the file-opening check away
from a live handle. An entry recorded its file only once its check had run, so
an aliased open arriving during that check made a second entry and ran the
check again; the file is now recorded when the entry is claimed, with a
separate flag for whether the check has run. And Close released the entry
before closing the database, so an open racing it could check — and open — a
file whose connection still held locks; the database closes first now.
---
internal/connector/ledger.go | 32 ++++++++++++++++++++++---------
internal/connector/ledger_test.go | 20 +++++++++++++++++++
2 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 957b24101..782a91a3f 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -272,8 +272,12 @@ func securePath(path string, create bool) error {
// released once, so a defensive extra call cannot take the entry away from
// another Ledger still holding the same file.
func (l *Ledger) Close() error {
+ // The database first, the entry after: between the two, an open racing
+ // this close must still find the entry, or its check would open the file
+ // while this connection still holds locks on it.
+ err := l.db.Close()
l.closed.Do(func() { releaseLedger(l.file) })
- return l.db.Close()
+ return err
}
// Opening one ledger file more than once in a process, safely.
@@ -311,8 +315,13 @@ type openLedgerFile struct {
// mu serializes the check itself, so opens that race each other on a
// fresh file do not verify against a check that has not run yet.
mu sync.Mutex
- // info is the file as the descriptor check saw it, nil until it has run.
+ // info is the file this entry is for, recorded when it was claimed, so
+ // another name for the same file finds this entry even before the check
+ // has run. It is nil only when the file did not exist yet.
info os.FileInfo
+ // checked says the descriptor check has run for this file; info is then
+ // what that check saw.
+ checked bool
}
// securePathRuns counts the checks that open the file. A test pins that a
@@ -333,7 +342,11 @@ func claimLedger(path string) *openLedgerFile {
}
file := openLedgers.files[path]
if file == nil {
- if info, err := os.Lstat(path); err == nil {
+ // Recorded at claim time, not at check time: an aliased open that
+ // arrives while the first one's check is still running must find this
+ // entry, since that check is the file-opening one.
+ info, err := os.Lstat(path)
+ if err == nil {
for _, open := range openLedgers.files {
if open.info != nil && os.SameFile(open.info, info) {
file = open
@@ -341,10 +354,10 @@ func claimLedger(path string) *openLedgerFile {
}
}
}
- }
- if file == nil {
- file = &openLedgerFile{key: path}
- openLedgers.files[path] = file
+ if file == nil {
+ file = &openLedgerFile{key: path, info: info}
+ openLedgers.files[path] = file
+ }
}
file.refs++
return file
@@ -363,18 +376,19 @@ func releaseLedger(file *openLedgerFile) {
func checkLedgerFile(file *openLedgerFile, path, abs string, owner bool) error {
file.mu.Lock()
defer file.mu.Unlock()
- if file.info != nil {
+ if file.checked {
return verifySameFile(abs, file.info)
}
securePathRuns.Add(1)
if err := securePath(path, owner); err != nil {
return err
}
+ // The check may have created the file, so what it saw is recorded now.
info, err := os.Lstat(abs)
if err != nil {
return fmt.Errorf("connector: inspect the ledger: %w", err)
}
- file.info = info
+ file.info, file.checked = info, true
return nil
}
diff --git a/internal/connector/ledger_test.go b/internal/connector/ledger_test.go
index c770af68f..50a1eb5d7 100644
--- a/internal/connector/ledger_test.go
+++ b/internal/connector/ledger_test.go
@@ -329,3 +329,23 @@ func TestClosingALedgerTwiceReleasesItOnce(t *testing.T) {
assert.Equal(t, checks, securePathRuns.Load(), "the first handle still holds the file")
}
+
+// An aliased open that arrives while the first opener's check is still
+// running must find that file's entry: that check is the one that opens the
+// file, and it is what the whole mechanism exists to hold off.
+func TestAnAliasClaimedDuringTheFirstCheckFindsTheSameFile(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ require.NoError(t, os.MkdirAll(dir, 0o700))
+ path := filepath.Join(dir, "connector.db")
+ require.NoError(t, os.WriteFile(path, nil, 0o600))
+ link := filepath.Join(dir, "hard.db")
+ require.NoError(t, os.Link(path, link))
+
+ // The first opener has claimed the file; its check has not run yet.
+ first := claimLedger(path)
+ defer releaseLedger(first)
+ second := claimLedger(link)
+ defer releaseLedger(second)
+
+ assert.Same(t, first, second, "one file, one entry, whatever it is called")
+}
From fca7015212ffdd4001897f1e968175b5026d0892 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 14:39:25 +0200
Subject: [PATCH 19/33] A pull is what hands work to a worker; a live task
keeps its conversation
Exposure written at launch was being read as a worker having the instruction.
It is the dispatcher's write: until the worker pulls (pulled_at), finished work
is not served to it and it can neither acknowledge nor complete anything. A
conversation is busy while a live task carries any of its events, not only
while a record is dispatched, so a task whose only event was settled before
the pull no longer lets a second task start beside it.
The database refuses attaching anything but work waiting for a worker, refuses
a superseded task taking new work, and refuses moving a row between tasks or
events. An empty instruction is no instruction.
The task token is read from its descriptor before the command tree runs at
all: Cobra's root hooks harden config, load profiles and may start an update
check, and a descriptor still open then is one a child could inherit.
---
internal/cli/root.go | 6 ++
internal/commands/mcp.go | 72 +++++++++++++-
.../commands/mcp_connect_token_unix_test.go | 57 +++++++++++
internal/connector/ledger.go | 15 +++
internal/connector/ledger_dispatch.go | 37 ++++---
internal/connector/ledger_dispatch_test.go | 98 +++++++++++++++++++
6 files changed, 272 insertions(+), 13 deletions(-)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index a68c83b4a..a74c9e116 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -303,6 +303,12 @@ func postRunNoticesEnabled(app *appctx.App) bool {
// Execute runs the root command.
func Execute() {
+ // Before anything else: a connector-started worker's task token arrives
+ // on an inherited descriptor, and the root command's persistent hooks —
+ // config hardening, profile loading, the update check — run before any
+ // command's own RunE and may start a process that would inherit it.
+ commands.TakeConnectTaskToken(os.Args[1:])
+
cmd := NewRootCmd()
// Add subcommands
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index cd0f55c10..d1f2121e8 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -8,6 +8,9 @@ import (
"os"
"os/signal"
"path/filepath"
+ "slices"
+ "strconv"
+ "strings"
"syscall"
"time"
@@ -29,6 +32,63 @@ var mcpTransport = func() mcp.Transport { return &mcp.StdioTransport{} }
// the server refuses to start, so nothing is led to hand it over that way.
const connectTaskTokenEnv = "BASECAMP_CONNECT_TASK_TOKEN"
+// takenTaskToken is the token TakeConnectTaskToken read, and whether it ran.
+// The descriptor is read before the command tree runs at all, so nothing this
+// process starts on the way — a config hardening pass, an update check, a
+// keychain helper — can inherit it.
+var takenTaskToken struct {
+ token string
+ err error
+ taken bool
+}
+
+// TakeConnectTaskToken reads the connector task token from the descriptor
+// args name, and closes it, before anything else in the process runs.
+//
+// Cobra runs the root command's persistent hooks before any command's own
+// RunE, and those hooks load configuration, tighten directories and may start
+// a background update check. A descriptor still open then is a descriptor a
+// child could inherit, so the read happens ahead of all of it, from the raw
+// arguments. What it found — the token, or the refusal — is the mcp command's
+// to use when it runs.
+func TakeConnectTaskToken(args []string) {
+ fd, ok := connectTokenFDArg(args)
+ if !ok {
+ return
+ }
+ takenTaskToken.taken = true
+ takenTaskToken.token, takenTaskToken.err = readTaskToken(fd)
+}
+
+// connectTokenFDArg finds --connect-token-fd in the raw arguments of an mcp
+// command. Anything malformed is left to Cobra and the command to report.
+func connectTokenFDArg(args []string) (int, bool) {
+ if !slices.Contains(args, "mcp") {
+ return 0, false
+ }
+ for i, arg := range args {
+ value, found := strings.CutPrefix(arg, "--connect-token-fd")
+ switch {
+ case !found:
+ continue
+ case strings.HasPrefix(value, "="):
+ value = value[1:]
+ case value != "":
+ continue // a longer flag that merely starts the same way
+ case i+1 < len(args):
+ value = args[i+1]
+ default:
+ return 0, false
+ }
+ fd, err := strconv.Atoi(value)
+ if err != nil {
+ return 0, false
+ }
+ return fd, true
+ }
+ return 0, false
+}
+
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
// 43 characters; anything near this is not one.
const maxTaskTokenBytes = 4096
@@ -93,7 +153,7 @@ func NewMCPCmd() *cobra.Command {
// the token or the ledger is touched.
return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
}
- token, err := readTaskToken(connectTokenFD)
+ token, err := connectTaskToken(connectTokenFD)
if err != nil {
return err
}
@@ -174,6 +234,16 @@ func stateDirHint(refusal *connector.StateDirError) string {
// agent's id comes from, and a ledger for another account is refused rather
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
+// connectTaskToken is what TakeConnectTaskToken read before the command tree
+// ran, or — when nothing did, as in a test that builds this command by hand —
+// the read done here.
+func connectTaskToken(fd int) (string, error) {
+ if takenTaskToken.taken {
+ return takenTaskToken.token, takenTaskToken.err
+ }
+ return readTaskToken(fd)
+}
+
func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
dir, agentID, err := connector.ResolveStateDir(stateDir, accountID)
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 6a629979c..ec925a902 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -187,3 +187,60 @@ func TestMCPCommandDoesNotWaitOnAWriteEndLeftOpen(t *testing.T) {
assert.Contains(t, err.Error(), "no task token arrived")
assert.Less(t, time.Since(started), 5*time.Second)
}
+
+// The token is read before the command tree runs at all: Cobra's root
+// persistent hooks load config, tighten directories and may start an update
+// check, and a descriptor still open then is one a child could inherit.
+func TestTakeConnectTaskTokenReadsBeforeTheCommandTree(t *testing.T) {
+ fd := tokenPipe(t, "a-task-token\n")
+ dev, ino, _ := fdIdentity(t, fd)
+ t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
+
+ TakeConnectTaskToken([]string{"mcp", "--connect-state", "/somewhere", "--connect-token-fd", strconv.Itoa(fd)})
+
+ require.True(t, takenTaskToken.taken)
+ require.NoError(t, takenTaskToken.err)
+ assert.Equal(t, "a-task-token", takenTaskToken.token)
+ if nowDev, nowIno, open := fdIdentity(t, fd); open {
+ assert.False(t, nowDev == dev && nowIno == ino, "the descriptor is closed already")
+ }
+}
+
+func TestTakeConnectTaskTokenIgnoresEverythingElse(t *testing.T) {
+ t.Cleanup(func() { takenTaskToken.taken = false })
+ for name, args := range map[string][]string{
+ "another command": {"projects", "list", "--connect-token-fd", "3"},
+ "no flag": {"mcp", "--read-only"},
+ "a flag that starts the same": {"mcp", "--connect-token-fdx", "3"},
+ "not a number": {"mcp", "--connect-token-fd", "three"},
+ "nothing after it": {"mcp", "--connect-token-fd"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ takenTaskToken.taken = false
+ TakeConnectTaskToken(args)
+ assert.False(t, takenTaskToken.taken, "left to Cobra and the command to report")
+ })
+ }
+
+ // Both spellings of the flag are read.
+ fd := tokenPipe(t, "token\n")
+ takenTaskToken.taken = false
+ TakeConnectTaskToken([]string{"mcp", "--connect-token-fd=" + strconv.Itoa(fd)})
+ require.True(t, takenTaskToken.taken)
+ assert.Equal(t, "token", takenTaskToken.token)
+}
+
+// The command serves from the token taken before the tree ran: by then the
+// descriptor is closed, so re-reading it would fail.
+func TestTheMCPCommandUsesTheTokenTakenAtStartup(t *testing.T) {
+ app, dir, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
+ fd := tokenPipe(t, grant.Token+"\n")
+ t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
+
+ TakeConnectTaskToken([]string{"mcp", "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd)})
+ require.True(t, takenTaskToken.taken)
+ require.NoError(t, takenTaskToken.err)
+
+ session := runMCPCommandWithApp(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd))
+ assert.Contains(t, toolNames(t, session), "basecamp_connect")
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 782a91a3f..6d6674c86 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -668,6 +668,21 @@ BEGIN
SELECT RAISE(ABORT, 'a guard only goes from armed to canceled or fired');
END;
+CREATE TRIGGER task_events_join_live_tasks_only
+BEFORE INSERT ON task_events
+WHEN EXISTS (SELECT 1 FROM tasks WHERE id = NEW.task_id AND superseded_at IS NOT NULL)
+ OR NOT EXISTS (SELECT 1 FROM events WHERE id = NEW.event_id AND state IN ('admitted', 'queued', 'dispatched'))
+BEGIN
+ SELECT RAISE(ABORT, 'only work waiting for a worker joins a task, and only a live one');
+END;
+
+CREATE TRIGGER task_events_do_not_move
+BEFORE UPDATE OF task_id, event_id ON task_events
+WHEN NEW.task_id <> OLD.task_id OR NEW.event_id <> OLD.event_id
+BEGIN
+ SELECT RAISE(ABORT, 'a task event belongs to the task and event it was written for');
+END;
+
CREATE TRIGGER task_events_delivery_moves_forward
BEFORE UPDATE OF delivery ON task_events
WHEN (CASE NEW.delivery WHEN 'admitted' THEN 0 WHEN 'exposed' THEN 1 WHEN 'delivered' THEN 2 ELSE 3 END)
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 7e041b7f2..26df99df0 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -116,7 +116,8 @@ import (
//
// 1. One live task per event: task_events_one_live_task.
// 2. One task per conversation: an event joins a task only if every
-// dispatched record on its conversation joins the same task (createTask).
+// dispatched record on its conversation joins the same task, and no live
+// task carries any of that conversation's events (createTask).
// 3. The token is valid only while its task is live, checked inside every
// worker call's own transaction.
// 4. Nothing leaves dispatched while a worker may still act: a record with a
@@ -131,7 +132,9 @@ import (
// 5. A worker acts only on its own task's rows, reports only what it was
// handed, and a reported outcome stands.
// 6. A task is made only of instructions a worker can pull, and finished
-// work is never handed out for the first time.
+// work is never handed out for the first time: a completed record is
+// served, acknowledged and completed only by the worker that pulled it
+// (pulled_at), never on the strength of an exposure written at launch.
// 7. Superseding retires the task's rows and returns only what it never
// exposed to admitted; what a worker was handed stays dispatched (4) and
// waits for its outcome or a redispatch, which supersedes and creates in
@@ -261,7 +264,7 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
acknowledge, hasInstruction int
state string
)
- switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL, state FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction, &state); {
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL AND length(snapshot) > 0, state FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction, &state); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
@@ -307,11 +310,18 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
}
var busy int64
//nolint:gosec // G202: placeholders, not values
+ //
+ // Busy is a record still dispatched on the conversation, or one a live
+ // task still carries: a task stays live until it is superseded, whatever
+ // became of its records, and two live tasks on one conversation would be
+ // two workers on it.
switch err := tx.QueryRowContext(ctx, `
SELECT other.id FROM events other
JOIN events mine ON mine.conversation_key = other.conversation_key
WHERE mine.id IN (`+placeholders+`) AND mine.conversation_key <> ''
- AND other.state = 'dispatched' AND other.id NOT IN (`+placeholders+`)
+ AND other.id NOT IN (`+placeholders+`)
+ AND (other.state = 'dispatched'
+ OR EXISTS (SELECT 1 FROM task_events WHERE event_id = other.id AND retired_at IS NULL))
LIMIT 1`, args...).Scan(&busy); {
case err == nil:
return TaskGrant{}, fmt.Errorf("connector: event %d is dispatched on the same conversation: %w", busy, ErrConversationBusy)
@@ -628,7 +638,7 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
if err != nil {
return Instruction{}, false, err
}
- if !servable(record, te.delivery) {
+ if !servable(record, te.pulled) {
return Instruction{}, false, fmt.Errorf("connector: event %d: %w", eventID, ErrNotDispatchable)
}
@@ -712,17 +722,18 @@ ORDER BY te.event_id LIMIT 1`, taskID).Scan(&eventID)
}
// servable is whether an event on a task is handed to its worker: its record
-// is dispatched, or completed after this worker was exposed to it — finished
-// work is never handed out for the first time — and it still has its
-// instruction. servableSQL is the same rule over task_events te and events e,
+// is dispatched, or completed after this worker pulled it — finished work is
+// never handed out for the first time — and it still has its instruction. servableSQL is the same rule over task_events te and events e,
// for the earliest-event query; the two are kept side by side so they cannot
// drift.
-func servable(record Record, delivery Delivery) bool {
- state := record.State == StateDispatched || (record.State == StateCompleted && delivery != DeliveryAdmitted)
+func servable(record Record, pulled bool) bool {
+ // Exposure at launch is the dispatcher's write, not a worker's pull, so
+ // finished work is served again only to a worker that already had it.
+ state := record.State == StateDispatched || (record.State == StateCompleted && pulled)
return state && !record.ContentDropped && len(record.Decision.Snapshot) > 0
}
-const servableSQL = `(e.state = 'dispatched' OR (e.state = 'completed' AND te.delivery <> 'admitted'))
+const servableSQL = `(e.state = 'dispatched' OR (e.state = 'completed' AND te.pulled_at IS NOT NULL))
AND e.content_dropped = 0 AND e.snapshot IS NOT NULL AND length(e.snapshot) > 0`
// Ack records the worker's acknowledgement: delivery moves to delivered, and
@@ -819,7 +830,9 @@ func (d *TaskDispatch) report(ctx context.Context, eventID int64, apply func(con
if err != nil {
return Receipt{}, err
}
- if te.delivery == DeliveryAdmitted {
+ if !te.pulled {
+ // Exposure at launch is not a worker having the instruction: the
+ // pull is. A worker reports only what it pulled.
return Receipt{}, fmt.Errorf("connector: event %d: %w", eventID, ErrNotExposed)
}
wrote, err := apply(ctx, tx, taskID, te)
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 33338dd43..ce755d4c8 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -1112,3 +1112,101 @@ func TestCreateTaskWritesNothingWhenItRefuses(t *testing.T) {
assert.Equal(t, tasksBefore, tasksAfter, "no task row")
assert.Equal(t, rowsBefore, rowsAfter, "no task event row")
}
+
+// Exposure written at launch is the dispatcher's word, not a worker's pull.
+// Until the worker pulls, finished work is not served to it, and it can
+// neither acknowledge nor complete anything.
+func TestALaunchExposureIsNotAPull(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ exposeAtLaunch := func(id int64) {
+ t.Helper()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = 'launch' WHERE event_id = ?`, id)
+ require.NoError(t, err)
+ }
+ exposeAtLaunch(1)
+ exposeAtLaunch(2)
+
+ _, err := f.d.Ack(ctx, 1, nil)
+ assert.ErrorIs(t, err, ErrNotExposed, "nothing was pulled yet")
+ _, err = f.d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ assert.ErrorIs(t, err, ErrNotExposed)
+
+ // Settled before the worker ever pulled it: not served, and not the
+ // earliest either.
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+ _, _, err = f.d.Get(ctx, 1)
+ assert.ErrorIs(t, err, ErrNotDispatchable)
+ got, ok, err := f.d.Get(ctx, 0)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(2), got.EventID)
+
+ // Event 2 the worker did pull, just now. Settled after that, it is served
+ // again to the worker that has it, and its report is taken.
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateCompleted, ""))
+ again, ok, err := f.d.Get(ctx, 2)
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(2), again.EventID)
+ _, err = f.d.Ack(ctx, 2, nil)
+ require.NoError(t, err)
+}
+
+// A task is live until it is superseded, whatever became of its records, so a
+// conversation whose only event was settled before the worker pulled it is
+// still busy.
+func TestALiveTaskKeepsItsConversationBusy(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+ require.NoError(t, f.ledger.SetState(ctx, 2, StateCompleted, ""))
+ seenRecord(t, f.ledger, 3)
+ _, err := f.ledger.Admission().Commit(ctx, admittedVerdict(3, 0, "recording:10304028989"))
+ require.NoError(t, err)
+
+ _, err = f.ledger.CreateTask(ctx, []int64{3})
+ require.ErrorIs(t, err, ErrConversationBusy, "the first task is still live")
+
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err = f.ledger.CreateTask(ctx, []int64{3})
+ require.NoError(t, err)
+}
+
+// A task is made of work waiting for a worker, on a live task, and its rows
+// stay where they were written — the database says so too.
+func TestTheDatabaseRefusesAttachingWorkToTheWrongTask(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ seenRecord(t, f.ledger, 3)
+
+ _, err := f.ledger.db.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id) VALUES (?, 3)`, f.grant.ID)
+ require.Error(t, err, "a seen record is not work waiting for a worker")
+
+ other, err := f.ledger.CreateTask(ctx, []int64{})
+ require.Error(t, err)
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err = f.ledger.Admission().Commit(ctx, admittedVerdict(3, 0, "recording:3"))
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id) VALUES (?, 3)`, f.grant.ID)
+ require.Error(t, err, "a superseded task takes no new work")
+ _ = other
+
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET task_id = 99 WHERE event_id = 1`)
+ require.Error(t, err, "a task event does not move between tasks")
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET event_id = 3 WHERE event_id = 1`)
+ require.Error(t, err, "nor between events")
+}
+
+// An instruction is content, not an empty blob: a record with one would be
+// dispatched and never servable.
+func TestCreateTaskRefusesAnEmptyInstruction(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE events SET snapshot = CAST('' AS BLOB) WHERE id = 1`)
+ require.NoError(t, err)
+
+ _, err = f.ledger.CreateTask(ctx, []int64{1})
+ require.ErrorIs(t, err, ErrNotDispatchable)
+}
From 9a1042fe743526f4af587e0972bbf1f09612f082 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 15:13:52 +0200
Subject: [PATCH 20/33] Read the token descriptor the way the flag parser will,
or not at all
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The startup scan used base ten and the first occurrence; pflag uses any base
and the last. On a spelling only one of them accepted, the descriptor was
drained and closed while the command served from another, or the scan missed
it and the read fell back into RunE — after the root hooks had run, which is
the window the startup read exists to close. The scan now reads the flag as
pflag does, stopping at a bare --, and there is no late read: a server whose
token was not taken at startup refuses to start. A test runs both parsers over
the same arguments.
An acknowledgement arriving after the outcome is refused rather than written,
and a worker holding an event reaches the model as not_dispatchable rather
than as a ledger fault.
---
internal/commands/mcp.go | 38 ++++++---
internal/commands/mcp_connect_test.go | 12 ++-
.../commands/mcp_connect_token_unix_test.go | 77 ++++++++++++++++---
internal/commands/mcp_test.go | 6 ++
internal/connector/ledger_dispatch.go | 5 ++
internal/mcpserver/connect.go | 1 +
6 files changed, 114 insertions(+), 25 deletions(-)
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index d1f2121e8..17ba7a557 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -61,32 +61,44 @@ func TakeConnectTaskToken(args []string) {
}
// connectTokenFDArg finds --connect-token-fd in the raw arguments of an mcp
-// command. Anything malformed is left to Cobra and the command to report.
+// command, reading it exactly as pflag will when the command runs: any base
+// Go accepts, the last occurrence winning, and nothing after a bare "--",
+// which is no longer a flag. The two must agree, or a spelling one of them
+// accepts and the other does not would read one descriptor and serve from
+// another. TestTheTokenPreScanAgreesWithTheFlagParser holds them together.
+//
+// Anything malformed is left to Cobra and the command to report.
func connectTokenFDArg(args []string) (int, bool) {
if !slices.Contains(args, "mcp") {
return 0, false
}
- for i, arg := range args {
- value, found := strings.CutPrefix(arg, "--connect-token-fd")
+ fd, found := 0, false
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if arg == "--" {
+ break
+ }
+ value, isFlag := strings.CutPrefix(arg, "--connect-token-fd")
switch {
- case !found:
+ case !isFlag:
continue
case strings.HasPrefix(value, "="):
value = value[1:]
case value != "":
continue // a longer flag that merely starts the same way
case i+1 < len(args):
- value = args[i+1]
+ i++
+ value = args[i]
default:
return 0, false
}
- fd, err := strconv.Atoi(value)
+ parsed, err := strconv.ParseInt(value, 0, 64)
if err != nil {
return 0, false
}
- return fd, true
+ fd, found = int(parsed), true
}
- return 0, false
+ return fd, found
}
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
@@ -235,13 +247,17 @@ func stateDirHint(refusal *connector.StateDirError) string {
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
// connectTaskToken is what TakeConnectTaskToken read before the command tree
-// ran, or — when nothing did, as in a test that builds this command by hand —
-// the read done here.
+// ran. Nothing reads the descriptor here: by now the persistent hooks have
+// run, and a descriptor still open through them is one a child could have
+// inherited. A server whose token was not taken at startup does not start.
func connectTaskToken(fd int) (string, error) {
if takenTaskToken.taken {
return takenTaskToken.token, takenTaskToken.err
}
- return readTaskToken(fd)
+ if fd >= 0 {
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d was not read at startup; the token descriptor is read before anything else runs", fd))
+ }
+ return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
}
func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 18c76d102..475dce344 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -148,16 +148,20 @@ func TestMCPCommandTakesTheTokenBeforeAuthenticating(t *testing.T) {
}
}
-func TestMCPCommandRefusesReadOnlyBeforeTouchingTheToken(t *testing.T) {
+// The token is read at startup, before the command knows its flags, so a
+// read-only server is refused after that read rather than before it — and the
+// descriptor is closed either way, never left open for a child to inherit.
+func TestMCPCommandRefusesToServeConnectReadOnly(t *testing.T) {
app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
- fd := tokenPipe(t, grant.Token)
+ fd := tokenPipe(t, grant.Token+"\n")
dev, ino, _ := fdIdentity(t, fd)
err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only", "--connect-token-fd", strconv.Itoa(fd))
require.Error(t, err)
assert.Contains(t, err.Error(), "read-only")
- nowDev, nowIno, open := fdIdentity(t, fd)
- assert.True(t, open && nowDev == dev && nowIno == ino, "the descriptor was not touched")
+ if nowDev, nowIno, open := fdIdentity(t, fd); open {
+ assert.False(t, nowDev == dev && nowIno == ino, "the descriptor is closed")
+ }
}
func TestMCPCommandWithoutConnectStateHasNoConnectDomain(t *testing.T) {
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index ec925a902..d56f14bf8 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -4,6 +4,7 @@ package commands
import (
"bytes"
+ "context"
"io/fs"
"os"
"path/filepath"
@@ -13,6 +14,8 @@ import (
"testing"
"time"
+ "github.com/basecamp/basecamp-cli/internal/appctx"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -230,17 +233,71 @@ func TestTakeConnectTaskTokenIgnoresEverythingElse(t *testing.T) {
assert.Equal(t, "token", takenTaskToken.token)
}
-// The command serves from the token taken before the tree ran: by then the
-// descriptor is closed, so re-reading it would fail.
-func TestTheMCPCommandUsesTheTokenTakenAtStartup(t *testing.T) {
- app, dir, grant, _ := connectMCPApp(t, "999", unusedUpstream(t).URL)
+// A server whose token was not taken at startup does not read the descriptor
+// late: by then the root hooks have run, and a descriptor still open through
+// them is one a child could have inherited. It refuses instead.
+func TestTheMCPCommandRefusesATokenNotTakenAtStartup(t *testing.T) {
+ t.Setenv("BASECAMP_TOKEN", "test-token")
+ app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
+ dir, grant, _ := connectStateWithTask(t)
fd := tokenPipe(t, grant.Token+"\n")
- t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
+ dev, ino, _ := fdIdentity(t, fd)
- TakeConnectTaskToken([]string{"mcp", "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd)})
- require.True(t, takenTaskToken.taken)
- require.NoError(t, takenTaskToken.err)
+ // The command on its own, as if startup had not scanned the arguments.
+ cmd := NewMCPCmd()
+ cmd.SetArgs([]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd)})
+ cmd.SetContext(appctx.WithApp(context.Background(), app))
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ err := cmd.Execute()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "was not read at startup")
+ nowDev, nowIno, open := fdIdentity(t, fd)
+ assert.True(t, open && nowDev == dev && nowIno == ino, "and it does not read the descriptor now")
+}
+
+// The startup scan and the flag parser must read --connect-token-fd the same
+// way. Where they disagree, one descriptor is drained and closed while the
+// command serves from another — or the scan misses a spelling and the read
+// falls to a point where a child could already have inherited it.
+func TestTheTokenPreScanAgreesWithTheFlagParser(t *testing.T) {
+ for _, argv := range [][]string{
+ {"mcp", "--connect-token-fd", "3"},
+ {"mcp", "--connect-token-fd=3"},
+ {"mcp", "--connect-token-fd=0x3"},
+ {"mcp", "--connect-token-fd=010"},
+ {"mcp", "--connect-token-fd", "3", "--connect-token-fd", "4"},
+ {"mcp", "--connect-token-fd=3", "--connect-token-fd=4"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
+ {"mcp", "--connect-token-fdx", "3"},
+ {"mcp", "--connect-token-fd", "three"},
+ {"mcp", "--connect-token-fd"},
+ {"mcp", "--read-only"},
+ } {
+ t.Run(strings.Join(argv, " "), func(t *testing.T) {
+ scanned, found := connectTokenFDArg(argv)
+
+ // What the command itself will see, from the flags it declares.
+ var parsed int
+ flags := NewMCPCmd().Flags()
+ parseErr := flags.Parse(argv[1:])
+ if parseErr == nil {
+ parsed, _ = flags.GetInt("connect-token-fd")
+ }
+ if parseErr != nil || !flags.Changed("connect-token-fd") {
+ assert.False(t, found, "the scan read a descriptor the command will not")
+ return
+ }
+ require.True(t, found, "the command will read a descriptor the scan missed")
+ assert.Equal(t, parsed, scanned)
+ })
+ }
+}
- session := runMCPCommandWithApp(t, app, "--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd))
- assert.Contains(t, toolNames(t, session), "basecamp_connect")
+// A bare -- ends the flags for pflag, so nothing after it is a descriptor to
+// read: cobra.NoArgs then refuses the command outright.
+func TestTheTokenPreScanStopsAtADoubleDash(t *testing.T) {
+ _, found := connectTokenFDArg([]string{"mcp", "--", "--connect-token-fd", "3"})
+ assert.False(t, found)
}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index b95695799..07ef27136 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -50,6 +50,12 @@ func setupMCPTestApp(t *testing.T, accountID, baseURL string) *appctx.App {
func executeMCPCommand(t *testing.T, app *appctx.App, args ...string) error {
t.Helper()
+ // As cli.Execute does, before the command tree runs at all.
+ takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
+ TakeConnectTaskToken(append([]string{"mcp"}, args...))
+ t.Cleanup(func() {
+ takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
+ })
cmd := NewMCPCmd()
cmd.SetArgs(args)
cmd.SetContext(appctx.WithApp(context.Background(), app))
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 26df99df0..22bb43614 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -747,6 +747,11 @@ func (d *TaskDispatch) Ack(ctx context.Context, eventID int64, ackID *int64) (Re
if ackID != nil && te.ackID.Valid && te.ackID.Int64 != *ackID {
return false, fmt.Errorf("connector: event %d acknowledged as %d: %w", eventID, te.ackID.Int64, ErrReportConflict)
}
+ if ackID != nil && !te.ackID.Valid && te.delivery == DeliveryCompleted {
+ // The outcome stands, and so does what was reported with it:
+ // an acknowledgement arriving after it is not written.
+ return false, fmt.Errorf("connector: event %d is completed: %w", eventID, ErrReportConflict)
+ }
if te.delivery != DeliveryExposed && (ackID == nil || te.ackID.Valid) {
return false, nil
}
diff --git a/internal/mcpserver/connect.go b/internal/mcpserver/connect.go
index 3393ad45c..deef530a9 100644
--- a/internal/mcpserver/connect.go
+++ b/internal/mcpserver/connect.go
@@ -238,6 +238,7 @@ func connectFailure(err error) *mcp.CallToolResult {
{connector.ErrReportConflict, "report_conflict"},
{connector.ErrNotDispatchable, "not_dispatchable"},
{connector.ErrInvalidReport, "invalid_report"},
+ {connector.ErrHeldByWorker, "not_dispatchable"},
} {
if errors.Is(err, known.err) {
message := known.err.Error()
From 6796808be54e9585c9dc6dfb72d125b22555fd96 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 15:29:06 +0200
Subject: [PATCH 21/33] Tie retirement and pulls to the task, and read only
this command's descriptor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A raw write could retire a live task's event, detaching a dispatched record
without superseding anything, and could stamp a pull on a retired or withdrawn
exposure — turning work proven to have reached no worker into work a worker
held. Both are refused in the database now.
The startup scan took any argument list containing the word mcp, so
`basecamp search -- mcp --connect-token-fd 3` would read and close a
descriptor for a command that is not this one. It now requires mcp to be the
command, skips a read-only server, which serves no connect domain, and refuses
a descriptor number too large to be one.
---
internal/commands/mcp.go | 34 +++++++++++++++++--
internal/commands/mcp_connect_test.go | 10 +++---
.../commands/mcp_connect_token_unix_test.go | 21 ++++++++++++
internal/connector/dispatch_lifecycle_test.go | 25 ++++++++++++++
internal/connector/ledger.go | 16 ++++++---
5 files changed, 92 insertions(+), 14 deletions(-)
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 17ba7a557..828ee1d75 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -5,10 +5,10 @@ import (
"errors"
"fmt"
"log/slog"
+ "math"
"os"
"os/signal"
"path/filepath"
- "slices"
"strconv"
"strings"
"syscall"
@@ -69,7 +69,7 @@ func TakeConnectTaskToken(args []string) {
//
// Anything malformed is left to Cobra and the command to report.
func connectTokenFDArg(args []string) (int, bool) {
- if !slices.Contains(args, "mcp") {
+ if !isMCPInvocation(args) {
return 0, false
}
fd, found := 0, false
@@ -93,7 +93,9 @@ func connectTokenFDArg(args []string) (int, bool) {
return 0, false
}
parsed, err := strconv.ParseInt(value, 0, 64)
- if err != nil {
+ if err != nil || parsed > math.MaxInt32 || parsed < math.MinInt32 {
+ // A descriptor number is small; anything else is not one, and
+ // narrowing it would not mean what was written.
return 0, false
}
fd, found = int(parsed), true
@@ -101,6 +103,32 @@ func connectTokenFDArg(args []string) (int, bool) {
return fd, found
}
+// isMCPInvocation reports arguments that run this command: "mcp" as the first
+// word that is not a flag or a flag's value, before any "--". A "mcp" further
+// along is an argument to something else.
+//
+// A read-only server is not one of them: it serves no connect domain, so
+// there is nothing to read a token for.
+func isMCPInvocation(args []string) bool {
+ command := ""
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "--":
+ return false
+ case arg == "--read-only":
+ return false
+ case strings.HasPrefix(arg, "-"):
+ if !strings.Contains(arg, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
+ i++ // its value
+ }
+ case command == "":
+ command = arg
+ }
+ }
+ return command == "mcp"
+}
+
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
// 43 characters; anything near this is not one.
const maxTaskTokenBytes = 4096
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 475dce344..0c5f1f5b8 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -148,9 +148,8 @@ func TestMCPCommandTakesTheTokenBeforeAuthenticating(t *testing.T) {
}
}
-// The token is read at startup, before the command knows its flags, so a
-// read-only server is refused after that read rather than before it — and the
-// descriptor is closed either way, never left open for a child to inherit.
+// A read-only server serves no connect domain, so the startup read skips it
+// entirely and the descriptor is left exactly as it was.
func TestMCPCommandRefusesToServeConnectReadOnly(t *testing.T) {
app, dir, grant, _ := connectMCPApp(t, "999", "https://3.basecampapi.com")
fd := tokenPipe(t, grant.Token+"\n")
@@ -159,9 +158,8 @@ func TestMCPCommandRefusesToServeConnectReadOnly(t *testing.T) {
err := executeMCPCommand(t, app, "--connect-state", dir, "--read-only", "--connect-token-fd", strconv.Itoa(fd))
require.Error(t, err)
assert.Contains(t, err.Error(), "read-only")
- if nowDev, nowIno, open := fdIdentity(t, fd); open {
- assert.False(t, nowDev == dev && nowIno == ino, "the descriptor is closed")
- }
+ nowDev, nowIno, open := fdIdentity(t, fd)
+ assert.True(t, open && nowDev == dev && nowIno == ino, "the descriptor was not touched")
}
func TestMCPCommandWithoutConnectStateHasNoConnectDomain(t *testing.T) {
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index d56f14bf8..f49556950 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -301,3 +301,24 @@ func TestTheTokenPreScanStopsAtADoubleDash(t *testing.T) {
_, found := connectTokenFDArg([]string{"mcp", "--", "--connect-token-fd", "3"})
assert.False(t, found)
}
+
+// "mcp" has to be the command, not a word somewhere in the arguments, and a
+// read-only server reads no token: it serves no connect domain.
+func TestTheTokenPreScanReadsOnlyThisCommandsDescriptor(t *testing.T) {
+ for name, args := range map[string][]string{
+ "another command's argument": {"search", "--", "mcp", "--connect-token-fd", "3"},
+ "a query that says mcp": {"search", "mcp", "--connect-token-fd", "3"},
+ "a flag value that says mcp": {"search", "--query", "mcp", "--connect-token-fd", "3"},
+ "read-only": {"mcp", "--read-only", "--connect-token-fd", "3"},
+ "a descriptor past a --": {"mcp", "--", "--connect-token-fd", "3"},
+ "out of range": {"mcp", "--connect-token-fd", "99999999999999"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, found := connectTokenFDArg(args)
+ assert.False(t, found)
+ })
+ }
+ fd, found := connectTokenFDArg([]string{"mcp", "--connect-state", "/x", "--connect-token-fd", "3"})
+ require.True(t, found)
+ assert.Equal(t, 3, fd)
+}
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index 07ed63447..f65e8bb93 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -430,3 +430,28 @@ func testTaskTransitions(t *testing.T) {
require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT superseded_at FROM tasks WHERE id = ?`, f.grant.ID).Scan(&again))
assert.Equal(t, first, again)
}
+
+// Retirement follows supersession, and a pull is recorded only on a live
+// exposure — in the database, so a raw writer meets the same rules.
+func TestTheDatabaseTiesRetirementAndPullsToTheirTask(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET retired_at = 'now' WHERE event_id = 1`)
+ require.Error(t, err, "a live task's events are not retired")
+
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'now' WHERE event_id = 1`)
+ require.Error(t, err, "a retired exposure is not pulled")
+
+ fresh := newDispatchFixture(t)
+ _, err = fresh.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+ tx, err := fresh.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ require.NoError(t, fresh.ledger.supersedeTask(ctx, tx, fresh.grant.ID))
+ require.NoError(t, fresh.ledger.withdrawExposure(ctx, tx, fresh.grant.ID, 1, StateAdmitted, ""))
+ require.NoError(t, tx.Commit())
+ _, err = fresh.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'now' WHERE event_id = 1`)
+ require.Error(t, err, "a withdrawn exposure is not pulled either")
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 6d6674c86..21a099d14 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -603,11 +603,14 @@ BEGIN
SELECT RAISE(ABORT, 'a superseded task stays superseded');
END;
-CREATE TRIGGER task_events_retirement_is_final
+CREATE TRIGGER task_events_retirement_follows_supersession
BEFORE UPDATE OF retired_at ON task_events
-WHEN OLD.retired_at IS NOT NULL AND NEW.retired_at IS NOT OLD.retired_at
+WHEN NEW.retired_at IS NOT OLD.retired_at AND (
+ OLD.retired_at IS NOT NULL
+ OR NEW.retired_at IS NULL
+ OR NOT EXISTS (SELECT 1 FROM tasks WHERE id = OLD.task_id AND superseded_at IS NOT NULL))
BEGIN
- SELECT RAISE(ABORT, 'a retired task event stays retired');
+ SELECT RAISE(ABORT, 'a task event is retired when its task is superseded, once');
END;
CREATE TRIGGER task_events_are_not_deleted
@@ -630,9 +633,12 @@ END;
CREATE TRIGGER task_events_pull_is_recorded_once
BEFORE UPDATE OF pulled_at ON task_events
-WHEN OLD.pulled_at IS NOT NULL AND NEW.pulled_at IS NOT OLD.pulled_at
+WHEN NEW.pulled_at IS NOT OLD.pulled_at AND (
+ OLD.pulled_at IS NOT NULL
+ OR OLD.retired_at IS NOT NULL
+ OR OLD.withdrawn_at IS NOT NULL)
BEGIN
- SELECT RAISE(ABORT, 'a pull is recorded once');
+ SELECT RAISE(ABORT, 'a pull is recorded once, and only on a live exposure');
END;
CREATE TRIGGER task_events_withdrawn_is_final
From a089855843ea97eb510d25c7bca5c0e879fe0671 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 15:51:45 +0200
Subject: [PATCH 22/33] Let cobra say which invocation this is, and the
database tie the rest together
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The startup read guessed at the arguments: it took any list with mcp in it,
missed --read-only=true, and treated root bool flags as taking a value, so it
could read a descriptor for a command it is not, or for one that serves no
connect domain. It now asks cobra to find the command as it will when it runs,
and parses what is left with the same flag types — and it reads only for an
mcp command with a state directory and no --read-only.
Four rules move into the database. Superseding a task retires its events in
the same write, so a token and its rows stop being live together. A pull is
recorded only on a live exposure. Acknowledging and completing need that pull,
with the dispatcher settling a completed record as the one other way. And what
the privacy check saw is published under the lock the alias scan reads it with.
A state directory whose account is not a number is misnamed rather than
another account's.
---
internal/cli/root.go | 14 ++-
internal/commands/mcp.go | 108 ++++++-----------
.../commands/mcp_connect_token_unix_test.go | 110 +++++++++++-------
internal/commands/mcp_test.go | 2 +-
internal/connector/dispatch_lifecycle_test.go | 45 ++++++-
internal/connector/ledger.go | 39 ++++++-
internal/connector/ledger_dispatch.go | 9 +-
7 files changed, 197 insertions(+), 130 deletions(-)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index a74c9e116..cbb811c6b 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -303,12 +303,6 @@ func postRunNoticesEnabled(app *appctx.App) bool {
// Execute runs the root command.
func Execute() {
- // Before anything else: a connector-started worker's task token arrives
- // on an inherited descriptor, and the root command's persistent hooks —
- // config hardening, profile loading, the update check — run before any
- // command's own RunE and may start a process that would inherit it.
- commands.TakeConnectTaskToken(os.Args[1:])
-
cmd := NewRootCmd()
// Add subcommands
@@ -383,6 +377,14 @@ func Execute() {
cmd.AddCommand(commands.NewMCPCmd())
cmd.AddCommand(commands.NewConnectCmd())
+ // Before the command runs: a connector-started worker's task token arrives
+ // on an inherited descriptor, and the root command's persistent hooks —
+ // config hardening, profile loading, the update check — run before any
+ // command's own RunE and may start a process that would inherit it. The
+ // command tree is built by now, so which invocation this is, and which
+ // descriptor it names, are cobra's answer rather than a guess.
+ commands.TakeConnectTaskToken(cmd, os.Args[1:])
+
// Tier-2 stdin guard: reject a stray literal "-" when stdin is piped,
// everywhere a command doesn't explicitly accept it — except cobra's
// generated meta commands, which are deliberately exempt (see
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 828ee1d75..b7d070dfa 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -4,18 +4,18 @@ import (
"context"
"errors"
"fmt"
+ "io"
"log/slog"
- "math"
"os"
"os/signal"
"path/filepath"
- "strconv"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/connector"
@@ -42,17 +42,21 @@ var takenTaskToken struct {
taken bool
}
-// TakeConnectTaskToken reads the connector task token from the descriptor
-// args name, and closes it, before anything else in the process runs.
+// TakeConnectTaskToken reads the connector task token from the descriptor the
+// arguments name, and closes it, before anything else in the process runs.
//
// Cobra runs the root command's persistent hooks before any command's own
// RunE, and those hooks load configuration, tighten directories and may start
-// a background update check. A descriptor still open then is a descriptor a
-// child could inherit, so the read happens ahead of all of it, from the raw
-// arguments. What it found — the token, or the refusal — is the mcp command's
-// to use when it runs.
-func TakeConnectTaskToken(args []string) {
- fd, ok := connectTokenFDArg(args)
+// a background update check. A descriptor still open then is one a child could
+// inherit, so the read happens ahead of all of it. What it found — the token,
+// or the refusal — is the mcp command's to use when it runs.
+//
+// Which arguments mean what is cobra's answer and pflag's, never a scan of our
+// own: root finds the command the way it will when it executes, and the same
+// flag types parse what is left. A hand-written scan reads a descriptor for an
+// invocation the command then refuses, or misses one it accepts.
+func TakeConnectTaskToken(root *cobra.Command, args []string) {
+ fd, ok := connectTokenFD(root, args)
if !ok {
return
}
@@ -60,73 +64,31 @@ func TakeConnectTaskToken(args []string) {
takenTaskToken.token, takenTaskToken.err = readTaskToken(fd)
}
-// connectTokenFDArg finds --connect-token-fd in the raw arguments of an mcp
-// command, reading it exactly as pflag will when the command runs: any base
-// Go accepts, the last occurrence winning, and nothing after a bare "--",
-// which is no longer a flag. The two must agree, or a spelling one of them
-// accepts and the other does not would read one descriptor and serve from
-// another. TestTheTokenPreScanAgreesWithTheFlagParser holds them together.
-//
-// Anything malformed is left to Cobra and the command to report.
-func connectTokenFDArg(args []string) (int, bool) {
- if !isMCPInvocation(args) {
+// connectTokenFD reports the descriptor to read: this command, serving the
+// connect domain, with a descriptor given. A read-only server serves no
+// connect domain, and a descriptor without a state directory is refused by the
+// command, so neither reads anything.
+func connectTokenFD(root *cobra.Command, args []string) (int, bool) {
+ target, rest, err := root.Find(args)
+ if err != nil || target == nil || target.Name() != "mcp" || target.Parent() == nil {
return 0, false
}
- fd, found := 0, false
- for i := 0; i < len(args); i++ {
- arg := args[i]
- if arg == "--" {
- break
- }
- value, isFlag := strings.CutPrefix(arg, "--connect-token-fd")
- switch {
- case !isFlag:
- continue
- case strings.HasPrefix(value, "="):
- value = value[1:]
- case value != "":
- continue // a longer flag that merely starts the same way
- case i+1 < len(args):
- i++
- value = args[i]
- default:
- return 0, false
- }
- parsed, err := strconv.ParseInt(value, 0, 64)
- if err != nil || parsed > math.MaxInt32 || parsed < math.MinInt32 {
- // A descriptor number is small; anything else is not one, and
- // narrowing it would not mean what was written.
- return 0, false
- }
- fd, found = int(parsed), true
- }
- return fd, found
-}
-// isMCPInvocation reports arguments that run this command: "mcp" as the first
-// word that is not a flag or a flag's value, before any "--". A "mcp" further
-// along is an argument to something else.
-//
-// A read-only server is not one of them: it serves no connect domain, so
-// there is nothing to read a token for.
-func isMCPInvocation(args []string) bool {
- command := ""
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--":
- return false
- case arg == "--read-only":
- return false
- case strings.HasPrefix(arg, "-"):
- if !strings.Contains(arg, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
- i++ // its value
- }
- case command == "":
- command = arg
- }
+ // The command's own flags, parsed as the command will parse them. The
+ // root's flags are unknown here and are skipped rather than guessed at.
+ flags := pflag.NewFlagSet("mcp", pflag.ContinueOnError)
+ flags.ParseErrorsWhitelist.UnknownFlags = true
+ flags.SetOutput(io.Discard)
+ readOnly := flags.Bool("read-only", false, "")
+ state := flags.String("connect-state", "", "")
+ fd := flags.Int("connect-token-fd", -1, "")
+ if err := flags.Parse(rest); err != nil {
+ return 0, false
+ }
+ if *readOnly || strings.TrimSpace(*state) == "" || !flags.Changed("connect-token-fd") {
+ return 0, false
}
- return command == "mcp"
+ return *fd, true
}
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index f49556950..7ab527a4e 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -16,6 +16,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/appctx"
+ "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -199,7 +200,7 @@ func TestTakeConnectTaskTokenReadsBeforeTheCommandTree(t *testing.T) {
dev, ino, _ := fdIdentity(t, fd)
t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
- TakeConnectTaskToken([]string{"mcp", "--connect-state", "/somewhere", "--connect-token-fd", strconv.Itoa(fd)})
+ TakeConnectTaskToken(testRootForMCP(t), []string{"mcp", "--connect-state", "/somewhere", "--connect-token-fd", strconv.Itoa(fd)})
require.True(t, takenTaskToken.taken)
require.NoError(t, takenTaskToken.err)
@@ -210,25 +211,27 @@ func TestTakeConnectTaskTokenReadsBeforeTheCommandTree(t *testing.T) {
}
func TestTakeConnectTaskTokenIgnoresEverythingElse(t *testing.T) {
- t.Cleanup(func() { takenTaskToken.taken = false })
+ t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
for name, args := range map[string][]string{
- "another command": {"projects", "list", "--connect-token-fd", "3"},
- "no flag": {"mcp", "--read-only"},
- "a flag that starts the same": {"mcp", "--connect-token-fdx", "3"},
- "not a number": {"mcp", "--connect-token-fd", "three"},
- "nothing after it": {"mcp", "--connect-token-fd"},
+ "another command": {"search", "list", "--connect-token-fd", "3"},
+ "no flag": {"mcp", "--connect-state", "/x"},
+ "a flag that starts the same": {"mcp", "--connect-state", "/x", "--connect-token-fdx", "3"},
+ "no state directory": {"mcp", "--connect-token-fd", "3"},
+ "read-only": {"mcp", "--connect-state", "/x", "--read-only", "--connect-token-fd", "3"},
+ "read-only, spelled out": {"mcp", "--connect-state", "/x", "--read-only=true", "--connect-token-fd", "3"},
+ "nothing after it": {"mcp", "--connect-state", "/x", "--connect-token-fd"},
} {
t.Run(name, func(t *testing.T) {
takenTaskToken.taken = false
- TakeConnectTaskToken(args)
+ TakeConnectTaskToken(testRootForMCP(t), args)
assert.False(t, takenTaskToken.taken, "left to Cobra and the command to report")
})
}
- // Both spellings of the flag are read.
+ // Both spellings of the flag are read, with the state directory given.
fd := tokenPipe(t, "token\n")
takenTaskToken.taken = false
- TakeConnectTaskToken([]string{"mcp", "--connect-token-fd=" + strconv.Itoa(fd)})
+ TakeConnectTaskToken(testRootForMCP(t), []string{"mcp", "--connect-state", "/x", "--connect-token-fd=" + strconv.Itoa(fd)})
require.True(t, takenTaskToken.taken)
assert.Equal(t, "token", takenTaskToken.token)
}
@@ -263,30 +266,37 @@ func TestTheMCPCommandRefusesATokenNotTakenAtStartup(t *testing.T) {
// falls to a point where a child could already have inherited it.
func TestTheTokenPreScanAgreesWithTheFlagParser(t *testing.T) {
for _, argv := range [][]string{
- {"mcp", "--connect-token-fd", "3"},
- {"mcp", "--connect-token-fd=3"},
- {"mcp", "--connect-token-fd=0x3"},
- {"mcp", "--connect-token-fd=010"},
- {"mcp", "--connect-token-fd", "3", "--connect-token-fd", "4"},
- {"mcp", "--connect-token-fd=3", "--connect-token-fd=4"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
- {"mcp", "--connect-token-fdx", "3"},
- {"mcp", "--connect-token-fd", "three"},
- {"mcp", "--connect-token-fd"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd=3"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd=0x3"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd=010"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--connect-token-fd", "4"},
+ {"mcp", "--connect-state=/x", "--connect-token-fd=3", "--connect-token-fd=4"},
+ {"--json", "mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
+ {"-v", "mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fdx", "3"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd", "three"},
+ {"mcp", "--connect-state", "/x", "--connect-token-fd"},
{"mcp", "--read-only"},
} {
t.Run(strings.Join(argv, " "), func(t *testing.T) {
- scanned, found := connectTokenFDArg(argv)
+ scanned, found := connectTokenFD(testRootForMCP(t), argv)
// What the command itself will see, from the flags it declares.
- var parsed int
- flags := NewMCPCmd().Flags()
- parseErr := flags.Parse(argv[1:])
- if parseErr == nil {
- parsed, _ = flags.GetInt("connect-token-fd")
- }
- if parseErr != nil || !flags.Changed("connect-token-fd") {
- assert.False(t, found, "the scan read a descriptor the command will not")
+ root := testRootForMCP(t)
+ target, rest, err := root.Find(argv)
+ require.NoError(t, err)
+ flags := target.Flags()
+ flags.AddFlagSet(root.PersistentFlags())
+ parseErr := flags.Parse(rest)
+ readOnly, _ := flags.GetBool("read-only")
+ state, _ := flags.GetString("connect-state")
+ parsed, _ := flags.GetInt("connect-token-fd")
+ wants := parseErr == nil && target.Name() == "mcp" && !readOnly &&
+ state != "" && flags.Changed("connect-token-fd")
+
+ if !wants {
+ assert.False(t, found, "the scan read a descriptor this invocation would not")
return
}
require.True(t, found, "the command will read a descriptor the scan missed")
@@ -298,7 +308,7 @@ func TestTheTokenPreScanAgreesWithTheFlagParser(t *testing.T) {
// A bare -- ends the flags for pflag, so nothing after it is a descriptor to
// read: cobra.NoArgs then refuses the command outright.
func TestTheTokenPreScanStopsAtADoubleDash(t *testing.T) {
- _, found := connectTokenFDArg([]string{"mcp", "--", "--connect-token-fd", "3"})
+ _, found := connectTokenFD(testRootForMCP(t), []string{"mcp", "--connect-state", "/x", "--", "--connect-token-fd", "3"})
assert.False(t, found)
}
@@ -306,19 +316,41 @@ func TestTheTokenPreScanStopsAtADoubleDash(t *testing.T) {
// read-only server reads no token: it serves no connect domain.
func TestTheTokenPreScanReadsOnlyThisCommandsDescriptor(t *testing.T) {
for name, args := range map[string][]string{
- "another command's argument": {"search", "--", "mcp", "--connect-token-fd", "3"},
- "a query that says mcp": {"search", "mcp", "--connect-token-fd", "3"},
- "a flag value that says mcp": {"search", "--query", "mcp", "--connect-token-fd", "3"},
- "read-only": {"mcp", "--read-only", "--connect-token-fd", "3"},
- "a descriptor past a --": {"mcp", "--", "--connect-token-fd", "3"},
- "out of range": {"mcp", "--connect-token-fd", "99999999999999"},
+ "another command's argument": {"search", "--", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
+ "a query that says mcp": {"search", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
+ "a root bool flag before it": {"--json", "search", "mcp", "--connect-token-fd", "3"},
+ "read-only": {"mcp", "--connect-state", "/x", "--read-only", "--connect-token-fd", "3"},
+ "read-only as a value": {"mcp", "--connect-state", "/x", "--read-only=1", "--connect-token-fd", "3"},
+ "a descriptor past a --": {"mcp", "--connect-state", "/x", "--", "--connect-token-fd", "3"},
+ "no state directory": {"mcp", "--connect-token-fd", "3"},
} {
t.Run(name, func(t *testing.T) {
- _, found := connectTokenFDArg(args)
+ _, found := connectTokenFD(testRootForMCP(t), args)
assert.False(t, found)
})
}
- fd, found := connectTokenFDArg([]string{"mcp", "--connect-state", "/x", "--connect-token-fd", "3"})
- require.True(t, found)
+ fd, found := connectTokenFD(testRootForMCP(t), []string{"--json", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"})
+ require.True(t, found, "a root flag before the command is the root's, not a value")
assert.Equal(t, 3, fd)
}
+
+// testRootForMCP is the command tree TakeConnectTaskToken resolves against:
+// a root carrying this command, as cli.Execute builds it.
+func testRootForMCP(t *testing.T) *cobra.Command {
+ t.Helper()
+ root := &cobra.Command{Use: "basecamp"}
+ root.PersistentFlags().Bool("json", false, "")
+ root.PersistentFlags().CountP("verbose", "v", "")
+ root.PersistentFlags().String("project", "", "")
+ root.AddCommand(NewMCPCmd())
+ root.AddCommand(&cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }})
+ return root
+}
+
+// A descriptor number no descriptor could have is refused where every other
+// bad one is: at the read, by asking the operating system about it.
+func TestABadDescriptorNumberIsRefusedAtTheRead(t *testing.T) {
+ _, err := readTaskToken(99999999)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not open")
+}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index 07ef27136..dbc2a0758 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -52,7 +52,7 @@ func executeMCPCommand(t *testing.T, app *appctx.App, args ...string) error {
t.Helper()
// As cli.Execute does, before the command tree runs at all.
takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
- TakeConnectTaskToken(append([]string{"mcp"}, args...))
+ TakeConnectTaskToken(testRootForMCP(t), append([]string{"mcp"}, args...))
t.Cleanup(func() {
takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
})
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index f65e8bb93..0daee3174 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -25,6 +25,7 @@ func TestDispatchLifecycleTable(t *testing.T) {
t.Run("worker actions", testWorkerActions)
t.Run("task", testTaskTransitions)
t.Run("withdrawal", testWithdrawal)
+ t.Run("a pull comes first", testDeliveryNeedsAPull)
}
// testWithdrawal: an exposure is withdrawn only on a superseded task, only
@@ -37,8 +38,12 @@ func testWithdrawal(t *testing.T) {
f := newDispatchFixture(t)
ctx := context.Background()
// Staged along the allowed steps, so the triggers are left in
- // place.
+ // place. Past exposed, the steps are a worker's, so it pulled.
for _, step := range deliveries[1 : slices.Index(deliveries, delivery)+1] {
+ if step == DeliveryDelivered {
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'pulled' WHERE event_id = 1`)
+ require.NoError(t, err)
+ }
_, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(step))
require.NoError(t, err)
}
@@ -223,6 +228,17 @@ func testDeliveryTransitions(t *testing.T) {
require.NoError(t, err)
_, err = f.ledger.db.ExecContext(ctx, `DROP TRIGGER task_events_exposure_comes_first`)
require.NoError(t, err)
+ // A worker pulled it: acknowledging and completing are what a
+ // worker does with what it pulled.
+ if from != DeliveryAdmitted {
+ // Past admitted, a worker pulled it, which it does while
+ // the row is exposed: acknowledging and completing are
+ // what a worker does with what it pulled.
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'pulled' WHERE event_id = 1`)
+ require.NoError(t, err)
+ }
_, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(from))
require.NoError(t, err)
reopened := f.ledger.restoreTriggers(t)
@@ -455,3 +471,30 @@ func TestTheDatabaseTiesRetirementAndPullsToTheirTask(t *testing.T) {
_, err = fresh.ledger.db.ExecContext(ctx, `UPDATE task_events SET pulled_at = 'now' WHERE event_id = 1`)
require.Error(t, err, "a withdrawn exposure is not pulled either")
}
+
+// Acknowledging and completing are what a worker does with what it pulled. An
+// exposure written at launch that no worker pulled moves no further, except
+// where the dispatcher settles the record itself.
+func testDeliveryNeedsAPull(t *testing.T) {
+ for _, to := range []Delivery{DeliveryDelivered, DeliveryCompleted} {
+ t.Run(string(to), func(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(to))
+ require.Error(t, err, "nothing was pulled")
+
+ // The dispatcher settling its record is the one other way to
+ // completed.
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = ? WHERE event_id = 1`, string(to))
+ if to == DeliveryCompleted {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ }
+ })
+ }
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 21a099d14..865731e9c 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -376,22 +376,34 @@ func releaseLedger(file *openLedgerFile) {
func checkLedgerFile(file *openLedgerFile, path, abs string, owner bool) error {
file.mu.Lock()
defer file.mu.Unlock()
- if file.checked {
- return verifySameFile(abs, file.info)
+ openLedgers.Lock()
+ checked, info := file.checked, file.info
+ openLedgers.Unlock()
+ if checked {
+ return verifySameFile(abs, info)
}
securePathRuns.Add(1)
if err := securePath(path, owner); err != nil {
return err
}
- // The check may have created the file, so what it saw is recorded now.
+ // The check may have created the file, so what it saw is recorded now —
+ // under the map's own lock, because that is where the alias scan reads it.
info, err := os.Lstat(abs)
if err != nil {
return fmt.Errorf("connector: inspect the ledger: %w", err)
}
- file.info, file.checked = info, true
+ recordCheckedFile(file, info)
return nil
}
+// recordCheckedFile publishes what the descriptor check saw, under the lock
+// the alias scan reads it with.
+func recordCheckedFile(file *openLedgerFile, info os.FileInfo) {
+ openLedgers.Lock()
+ defer openLedgers.Unlock()
+ file.info, file.checked = info, true
+}
+
// verifySameFile holds a second open to what the first one's check
// established, without opening anything.
func verifySameFile(path string, checked os.FileInfo) error {
@@ -603,6 +615,17 @@ BEGIN
SELECT RAISE(ABORT, 'a superseded task stays superseded');
END;
+-- Retirement is not a second step anyone can forget or skip: superseding a
+-- task retires its events in the same write, so a task's token and its rows
+-- stop being live together.
+CREATE TRIGGER tasks_supersession_retires_its_events
+AFTER UPDATE OF superseded_at ON tasks
+WHEN NEW.superseded_at IS NOT NULL AND OLD.superseded_at IS NULL
+BEGIN
+ UPDATE task_events SET retired_at = NEW.superseded_at
+ WHERE task_id = NEW.id AND retired_at IS NULL;
+END;
+
CREATE TRIGGER task_events_retirement_follows_supersession
BEFORE UPDATE OF retired_at ON task_events
WHEN NEW.retired_at IS NOT OLD.retired_at AND (
@@ -635,6 +658,7 @@ CREATE TRIGGER task_events_pull_is_recorded_once
BEFORE UPDATE OF pulled_at ON task_events
WHEN NEW.pulled_at IS NOT OLD.pulled_at AND (
OLD.pulled_at IS NOT NULL
+ OR OLD.delivery <> 'exposed'
OR OLD.retired_at IS NOT NULL
OR OLD.withdrawn_at IS NOT NULL)
BEGIN
@@ -699,9 +723,12 @@ END;
CREATE TRIGGER task_events_exposure_comes_first
BEFORE UPDATE OF delivery ON task_events
-WHEN OLD.delivery = 'admitted' AND NEW.delivery IN ('delivered', 'completed')
+WHEN (OLD.delivery = 'admitted' AND NEW.delivery IN ('delivered', 'completed'))
+ OR (NEW.delivery = 'delivered' AND OLD.delivery <> 'delivered' AND OLD.pulled_at IS NULL)
+ OR (NEW.delivery = 'completed' AND OLD.delivery <> 'completed' AND OLD.pulled_at IS NULL
+ AND NOT EXISTS (SELECT 1 FROM events WHERE id = OLD.event_id AND state = 'completed'))
BEGIN
- SELECT RAISE(ABORT, 'nothing a worker was never handed is acknowledged or completed');
+ SELECT RAISE(ABORT, 'a worker acknowledges and completes what it pulled; anything else is the dispatcher settling a completed record');
END;
`,
}
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 22bb43614..1360c393b 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -417,9 +417,6 @@ func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) er
if _, err := tx.ExecContext(ctx, `UPDATE tasks SET superseded_at = COALESCE(superseded_at, ?) WHERE id = ?`, now, taskID); err != nil {
return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
}
- if _, err := tx.ExecContext(ctx, `UPDATE task_events SET retired_at = COALESCE(retired_at, ?) WHERE task_id = ?`, now, taskID); err != nil {
- return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
- }
for _, id := range unexposed {
// Only a record still dispatched moves: one a person or a later
// verdict already moved stays where it was put.
@@ -1312,8 +1309,12 @@ func ResolveStateDir(dir, accountID string) (string, int64, error) {
return refuse(StateDirMisnamed, account)
}
given, errGiven := strconv.ParseUint(account, 10, 64)
+ if errGiven != nil || given == 0 {
+ // Not an account at all: the name is wrong, not another account's.
+ return refuse(StateDirMisnamed, account)
+ }
want, errWant := strconv.ParseUint(accountID, 10, 64)
- if errGiven != nil || errWant != nil || given == 0 || given != want {
+ if errWant != nil || given != want {
return refuse(StateDirOtherAccount, account)
}
return abs, agentID, nil
From efc7c4a7f17c87ca8634fa2a5ca425bb2bae9783 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 16:15:41 +0200
Subject: [PATCH 23/33] Hold a later open to the owner the check passed, and
read for one invocation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three findings, and two declined. A second Ledger on a live file checked
identity and permissions but not ownership, which is the actual trust
boundary: it now requires the file to be this user's own and the same owner
the descriptor check passed. A BEFORE trigger reads the row as it was, so one
statement could pull and withdraw together, each condition seeing the other's
old NULL; both now read the row being written as well. And the startup read
and the command now share one rule for whether a state directory was given, so
a blank one is absent to both.
The startup read also parses with the command's own flags rather than three of
its own, and reads nothing for --help, --version, or an invocation cobra will
refuse — so no descriptor is drained for a run that serves nothing.
Declined, per the coordinator: hardening the insert trigger against raw SQL.
The triggers are there to hold this package's writes, and a second connector's,
to the lifecycle. Anyone running raw SQL against the ledger already owns the
operator's private file and could edit or replace it; the boundary there is
file ownership and the private-path check, which is what the comment now says.
---
internal/commands/mcp.go | 37 ++++++++++++++-----
.../commands/mcp_connect_token_unix_test.go | 33 +++++++++++++++++
internal/connector/dispatch_lifecycle_test.go | 24 ++++++++++++
internal/connector/ledger.go | 23 +++++++++++-
internal/connector/ledger_test.go | 31 ++++++++++++++++
internal/connector/owner_other.go | 12 ++++++
internal/connector/owner_unix.go | 23 ++++++++++++
internal/connector/strip_mentions_test.go | 2 +-
8 files changed, 173 insertions(+), 12 deletions(-)
create mode 100644 internal/connector/owner_other.go
create mode 100644 internal/connector/owner_unix.go
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index b7d070dfa..6271e47fa 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -64,6 +64,11 @@ func TakeConnectTaskToken(root *cobra.Command, args []string) {
takenTaskToken.token, takenTaskToken.err = readTaskToken(fd)
}
+// connectStateGiven is the one rule for whether a state directory was given,
+// used by the startup read and by the command, so they never disagree about
+// an invocation.
+func connectStateGiven(state string) bool { return strings.TrimSpace(state) != "" }
+
// connectTokenFD reports the descriptor to read: this command, serving the
// connect domain, with a descriptor given. A read-only server serves no
// connect domain, and a descriptor without a state directory is refused by the
@@ -74,21 +79,33 @@ func connectTokenFD(root *cobra.Command, args []string) (int, bool) {
return 0, false
}
- // The command's own flags, parsed as the command will parse them. The
- // root's flags are unknown here and are skipped rather than guessed at.
+ // The command's own flags and the root's, as the command will see them:
+ // the definitions are the command's, so a flag it does not accept fails
+ // here exactly as it will there, and nothing is read for an invocation
+ // cobra is about to refuse.
flags := pflag.NewFlagSet("mcp", pflag.ContinueOnError)
- flags.ParseErrorsWhitelist.UnknownFlags = true
flags.SetOutput(io.Discard)
- readOnly := flags.Bool("read-only", false, "")
- state := flags.String("connect-state", "", "")
- fd := flags.Int("connect-token-fd", -1, "")
+ flags.AddFlagSet(target.Flags())
+ flags.AddFlagSet(target.Root().PersistentFlags())
+ if flags.Lookup("help") == nil {
+ flags.BoolP("help", "h", false, "")
+ }
if err := flags.Parse(rest); err != nil {
return 0, false
}
- if *readOnly || strings.TrimSpace(*state) == "" || !flags.Changed("connect-token-fd") {
+ if help, _ := flags.GetBool("help"); help {
+ return 0, false // cobra prints help and serves nothing
+ }
+ if version, err := flags.GetBool("version"); err == nil && version {
+ return 0, false
+ }
+ readOnly, _ := flags.GetBool("read-only")
+ state, _ := flags.GetString("connect-state")
+ fd, err := flags.GetInt("connect-token-fd")
+ if err != nil || readOnly || !connectStateGiven(state) || !flags.Changed("connect-token-fd") {
return 0, false
}
- return *fd, true
+ return fd, true
}
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
@@ -147,7 +164,7 @@ func NewMCPCmd() *cobra.Command {
"Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
}
switch {
- case connectState == "" && cmd.Flags().Changed("connect-token-fd"):
+ case !connectStateGiven(connectState) && cmd.Flags().Changed("connect-token-fd"):
return output.ErrUsage("--connect-token-fd is only for a server started with --connect-state")
case connectState != "":
if readOnly {
@@ -181,7 +198,7 @@ func NewMCPCmd() *cobra.Command {
}
cfg := mcpserver.Config{ReadOnly: readOnly, Domains: domains}
- if connectState != "" {
+ if connectStateGiven(connectState) {
dispatch, closeLedger, err := openConnectDispatch(cmd.Context(), connectState, app.Config.AccountID, taskToken)
if err != nil {
return err
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 7ab527a4e..591c7ebb4 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -354,3 +354,36 @@ func TestABadDescriptorNumberIsRefusedAtTheRead(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "not open")
}
+
+// Nothing is read for an invocation that serves nothing: help, a flag the
+// command does not accept, or a state directory that is only whitespace —
+// which the command reads as absent too, so the two never disagree.
+func TestNothingIsReadForAnInvocationThatServesNothing(t *testing.T) {
+ for name, args := range map[string][]string{
+ "help": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--help"},
+ "help, short": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "-h"},
+ "a flag it refuses": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--bogus"},
+ "a missing value": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--domains"},
+ "blank state": {"mcp", "--connect-state", " ", "--connect-token-fd", "3"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, found := connectTokenFD(testRootForMCP(t), args)
+ assert.False(t, found)
+ })
+ }
+}
+
+// And the command reads a whitespace state directory as absent as well, so it
+// refuses the descriptor rather than reporting a token that was never read.
+func TestABlankStateDirectoryIsNoStateDirectory(t *testing.T) {
+ t.Setenv("BASECAMP_TOKEN", "test-token")
+ app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
+ fd := tokenPipe(t, "token\n")
+ dev, ino, _ := fdIdentity(t, fd)
+
+ err := executeMCPCommand(t, app, "--connect-state", " ", "--connect-token-fd", strconv.Itoa(fd))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--connect-token-fd is only for a server started with --connect-state")
+ nowDev, nowIno, open := fdIdentity(t, fd)
+ assert.True(t, open && nowDev == dev && nowIno == ino, "and the descriptor was not touched")
+}
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index 0daee3174..a2810ebe0 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -498,3 +498,27 @@ func testDeliveryNeedsAPull(t *testing.T) {
})
}
}
+
+// A pull and a withdrawal are opposites: one says a worker has the
+// instruction, the other that none ever did. One statement cannot write both,
+// and a BEFORE trigger that read only the row as it was would let it.
+func TestOneWriteCannotBothPullAndWithdraw(t *testing.T) {
+ for name, statement := range map[string]string{
+ "pull and withdraw": `UPDATE task_events SET pulled_at = 'now', withdrawn_at = 'now' WHERE event_id = 1`,
+ "withdraw and pull": `UPDATE task_events SET withdrawn_at = 'now', pulled_at = 'now' WHERE event_id = 1`,
+ "pull and retire": `UPDATE task_events SET pulled_at = 'now', retired_at = 'now' WHERE event_id = 1`,
+ "pull and move on": `UPDATE task_events SET pulled_at = 'now', delivery = 'delivered' WHERE event_id = 1`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ f := newDispatchFixture(t)
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed' WHERE event_id = 1`)
+ require.NoError(t, err)
+
+ _, err = f.ledger.db.ExecContext(ctx, statement)
+
+ require.Error(t, err)
+ assert.Equal(t, "exposed", f.rowContext(ctx, t, 1).Delivery)
+ })
+ }
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 865731e9c..2a8991bba 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -404,6 +404,8 @@ func recordCheckedFile(file *openLedgerFile, info os.FileInfo) {
file.info, file.checked = info, true
}
+// ownedByThisUser and sameOwner read owners; see owner_unix.go.
+//
// verifySameFile holds a second open to what the first one's check
// established, without opening anything.
func verifySameFile(path string, checked os.FileInfo) error {
@@ -411,6 +413,12 @@ func verifySameFile(path string, checked os.FileInfo) error {
if err != nil {
return fmt.Errorf("connector: secure the ledger: %w", err)
}
+ // The first check established whose file it is. Ownership can change
+ // under an open handle, and a ledger that is no longer this user's own —
+ // or no longer the owner the check passed — is not one to read.
+ if !ownedByThisUser(info) || !sameOwner(info, checked) {
+ return fmt.Errorf("connector: secure the ledger: %s is no longer owned by the user the check passed", path)
+ }
if !info.Mode().IsRegular() || !os.SameFile(info, checked) {
return fmt.Errorf("connector: secure the ledger: %s: %w", path, ErrLedgerNotTheSameFile)
}
@@ -648,6 +656,9 @@ WHEN NEW.withdrawn_at IS NOT OLD.withdrawn_at AND (
OLD.withdrawn_at IS NOT NULL
OR OLD.delivery <> 'exposed'
OR OLD.pulled_at IS NOT NULL
+ -- Nor can one statement withdraw and pull, or withdraw and move the row.
+ OR NEW.pulled_at IS NOT NULL
+ OR NEW.delivery <> 'exposed'
OR NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = OLD.task_id AND tasks.superseded_at IS NOT NULL)
OR EXISTS (SELECT 1 FROM task_events live WHERE live.event_id = OLD.event_id AND live.retired_at IS NULL))
BEGIN
@@ -660,7 +671,12 @@ WHEN NEW.pulled_at IS NOT OLD.pulled_at AND (
OLD.pulled_at IS NOT NULL
OR OLD.delivery <> 'exposed'
OR OLD.retired_at IS NOT NULL
- OR OLD.withdrawn_at IS NOT NULL)
+ OR OLD.withdrawn_at IS NOT NULL
+ -- The same statement cannot both pull and retire or withdraw: these are a
+ -- BEFORE trigger's conditions, so the row being written is read as well.
+ OR NEW.retired_at IS NOT NULL
+ OR NEW.withdrawn_at IS NOT NULL
+ OR NEW.delivery <> 'exposed')
BEGIN
SELECT RAISE(ABORT, 'a pull is recorded once, and only on a live exposure');
END;
@@ -698,6 +714,11 @@ BEGIN
SELECT RAISE(ABORT, 'a guard only goes from armed to canceled or fired');
END;
+-- What these triggers are for: holding this package's own writes, and those
+-- of a second connector on the same file, to the lifecycle. They are not a
+-- defense against raw SQL. Anyone who can run that already has write access to
+-- the operator's private ledger and could edit or replace the file; the trust
+-- boundary there is file ownership and the private-path check, not a trigger.
CREATE TRIGGER task_events_join_live_tasks_only
BEFORE INSERT ON task_events
WHEN EXISTS (SELECT 1 FROM tasks WHERE id = NEW.task_id AND superseded_at IS NOT NULL)
diff --git a/internal/connector/ledger_test.go b/internal/connector/ledger_test.go
index 50a1eb5d7..20b8c7bca 100644
--- a/internal/connector/ledger_test.go
+++ b/internal/connector/ledger_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "syscall"
"testing"
"time"
@@ -349,3 +350,33 @@ func TestAnAliasClaimedDuringTheFirstCheckFindsTheSameFile(t *testing.T) {
assert.Same(t, first, second, "one file, one entry, whatever it is called")
}
+
+// Ownership is read from the file itself: a ledger this user does not own is
+// not one this process may read, whoever else can see it.
+func TestOwnedByThisUser(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "ledger.db")
+ require.NoError(t, os.WriteFile(path, nil, 0o600))
+ info, err := os.Lstat(path)
+ require.NoError(t, err)
+
+ assert.True(t, ownedByThisUser(info))
+ assert.True(t, sameOwner(info, info))
+ assert.False(t, sameOwner(info, otherOwner{info}), "the owner changed since the check")
+ err = verifySameFile(path, otherOwner{info})
+ require.Error(t, err, "a second open is held to the owner the check passed")
+ assert.Contains(t, err.Error(), "no longer owned by the user the check passed")
+ assert.False(t, ownedByThisUser(otherOwner{info}), "another user's file")
+ assert.False(t, ownedByThisUser(noOwner{info}), "a file whose owner cannot be read")
+}
+
+type otherOwner struct{ os.FileInfo }
+
+func (o otherOwner) Sys() any {
+ stat := *o.FileInfo.Sys().(*syscall.Stat_t)
+ stat.Uid++
+ return &stat
+}
+
+type noOwner struct{ os.FileInfo }
+
+func (noOwner) Sys() any { return nil }
diff --git a/internal/connector/owner_other.go b/internal/connector/owner_other.go
new file mode 100644
index 000000000..e0fa3fbb8
--- /dev/null
+++ b/internal/connector/owner_other.go
@@ -0,0 +1,12 @@
+//go:build !unix
+
+package connector
+
+import "os"
+
+// ownedByThisUser cannot be answered without POSIX owners, and a ledger whose
+// privacy cannot be established is refused rather than opened.
+func ownedByThisUser(os.FileInfo) bool { return false }
+
+// sameOwner cannot be answered without POSIX owners.
+func sameOwner(os.FileInfo, os.FileInfo) bool { return false }
diff --git a/internal/connector/owner_unix.go b/internal/connector/owner_unix.go
new file mode 100644
index 000000000..a335360df
--- /dev/null
+++ b/internal/connector/owner_unix.go
@@ -0,0 +1,23 @@
+//go:build unix
+
+package connector
+
+import (
+ "os"
+ "syscall"
+)
+
+// ownedByThisUser reports a file this user owns. The connector runs on Unix
+// only — its ledger's privacy cannot be established elsewhere — so this is
+// where ownership is read.
+func ownedByThisUser(info os.FileInfo) bool {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ return ok && int(stat.Uid) == os.Getuid()
+}
+
+// sameOwner reports two stats of a file with the same owner.
+func sameOwner(a, b os.FileInfo) bool {
+ left, okA := a.Sys().(*syscall.Stat_t)
+ right, okB := b.Sys().(*syscall.Stat_t)
+ return okA && okB && left.Uid == right.Uid
+}
diff --git a/internal/connector/strip_mentions_test.go b/internal/connector/strip_mentions_test.go
index 463d01ca9..294bac49a 100644
--- a/internal/connector/strip_mentions_test.go
+++ b/internal/connector/strip_mentions_test.go
@@ -10,7 +10,7 @@ import (
// StripMentionsOf is held to agreement with the reader admission decides the
// trigger with, basecamp.MentionedPersonIDs, over markup built to make two
-// parsers disagree. Three properties, for every input:
+// parsers disagree. Four properties, for every input:
//
// 1. no mention of the agent survives;
// 2. every other person the reader found is still found, in order;
From 670c82e665b3354c06fdaf9ed2aea8ae67c95b72 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 16:37:51 +0200
Subject: [PATCH 24/33] Read the arguments without touching the command that
runs them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The startup read bound to the live command's flags, and pflag's slice and
count values append and increment once a value has been set: cobra then parsed
the same arguments again, so basecamp mcp -v ran at verbose 2 and --domains
arrived doubled. It now parses a command of its own, with the root's flags
copied by shape — name, shorthand, and whether they take a value — so the
arguments parse exactly as the command will parse them and nothing the command
runs is written to. Positional arguments are refused here as cobra.NoArgs will
refuse them.
Ownership compares the effective uid, which is what the private-path check
uses, and the ownership test moved behind a Unix tag so the tree still vets
for Windows. A stale environment token is refused before any descriptor is
read, so it cannot cost the connector its handoff, and a descriptor whose mode
cannot be changed is closed rather than left open.
A completion writes the delivery before the record, so the delivery triggers
see a worker's completion as one; an acknowledgement after the outcome is
refused; and a withdrawn exposure does not follow its record onto the retry —
all three now have tests.
---
internal/commands/mcp.go | 58 ++++++++++++++-----
.../commands/mcp_connect_token_unix_test.go | 14 -----
internal/commands/mcp_test.go | 35 +++++++++++
internal/commands/mcp_token_unix.go | 12 ++--
internal/connector/ledger_dispatch.go | 26 ++++++---
internal/connector/ledger_dispatch_test.go | 51 ++++++++++++++++
internal/connector/ledger_owner_unix_test.go | 43 ++++++++++++++
internal/connector/ledger_test.go | 31 ----------
internal/connector/owner_unix.go | 5 +-
9 files changed, 203 insertions(+), 72 deletions(-)
create mode 100644 internal/connector/ledger_owner_unix_test.go
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 6271e47fa..08b968c6e 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -61,9 +61,27 @@ func TakeConnectTaskToken(root *cobra.Command, args []string) {
return
}
takenTaskToken.taken = true
+ // The environment is refused before the descriptor is read, so a stale
+ // token there does not cost the connector its handoff, and it is out of
+ // the environment before the hooks that could pass it to a child.
+ if _, set := os.LookupEnv(connectTaskTokenEnv); set {
+ _ = os.Unsetenv(connectTaskTokenEnv)
+ takenTaskToken.err = output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
+ "Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
+ return
+ }
takenTaskToken.token, takenTaskToken.err = readTaskToken(fd)
}
+// discardedValue stands in for a flag this read does not care about: it keeps
+// the flag's shape, so the arguments parse as the command will parse them, and
+// keeps none of its value.
+type discardedValue struct{ kind string }
+
+func (discardedValue) String() string { return "" }
+func (discardedValue) Set(string) error { return nil }
+func (v discardedValue) Type() string { return v.kind }
+
// connectStateGiven is the one rule for whether a state directory was given,
// used by the startup read and by the command, so they never disagree about
// an invocation.
@@ -79,26 +97,38 @@ func connectTokenFD(root *cobra.Command, args []string) (int, bool) {
return 0, false
}
- // The command's own flags and the root's, as the command will see them:
- // the definitions are the command's, so a flag it does not accept fails
- // here exactly as it will there, and nothing is read for an invocation
- // cobra is about to refuse.
- flags := pflag.NewFlagSet("mcp", pflag.ContinueOnError)
+ // This command's own flags, on a command of its own: the definitions are
+ // the real ones, so a flag it does not accept, or a value it needs and
+ // does not get, fails here exactly as it will there — and nothing the
+ // command will actually run is touched, because binding to the live
+ // command's flags would set its values and count its counters twice.
+ // The root's flags are unknown here and are skipped rather than guessed.
+ flags := NewMCPCmd().Flags()
+ flags.Init("mcp", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
- flags.AddFlagSet(target.Flags())
- flags.AddFlagSet(target.Root().PersistentFlags())
- if flags.Lookup("help") == nil {
- flags.BoolP("help", "h", false, "")
- }
+ flags.BoolP("help", "h", false, "")
+ // The root's flags may appear anywhere, and what they are is the root's
+ // business: each is copied by shape alone — name, shorthand, and whether
+ // it takes a value — onto a value that keeps nothing. So this parse
+ // accepts exactly what the command will accept, and a flag neither of
+ // them knows is refused here as it will be there.
+ target.Root().PersistentFlags().VisitAll(func(f *pflag.Flag) {
+ if flags.Lookup(f.Name) != nil {
+ return
+ }
+ copied := flags.VarPF(discardedValue{kind: f.Value.Type()}, f.Name, f.Shorthand, f.Usage)
+ copied.NoOptDefVal = f.NoOptDefVal
+ })
if err := flags.Parse(rest); err != nil {
return 0, false
}
+ if flags.NArg() > 0 {
+ return 0, false // cobra.NoArgs refuses it
+ }
if help, _ := flags.GetBool("help"); help {
return 0, false // cobra prints help and serves nothing
}
- if version, err := flags.GetBool("version"); err == nil && version {
- return 0, false
- }
+
readOnly, _ := flags.GetBool("read-only")
state, _ := flags.GetString("connect-state")
fd, err := flags.GetInt("connect-token-fd")
@@ -166,7 +196,7 @@ func NewMCPCmd() *cobra.Command {
switch {
case !connectStateGiven(connectState) && cmd.Flags().Changed("connect-token-fd"):
return output.ErrUsage("--connect-token-fd is only for a server started with --connect-state")
- case connectState != "":
+ case connectStateGiven(connectState):
if readOnly {
// Every connect action records something; refused before
// the token or the ledger is touched.
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 591c7ebb4..3b7cca86d 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -16,7 +16,6 @@ import (
"github.com/basecamp/basecamp-cli/internal/appctx"
- "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -334,19 +333,6 @@ func TestTheTokenPreScanReadsOnlyThisCommandsDescriptor(t *testing.T) {
assert.Equal(t, 3, fd)
}
-// testRootForMCP is the command tree TakeConnectTaskToken resolves against:
-// a root carrying this command, as cli.Execute builds it.
-func testRootForMCP(t *testing.T) *cobra.Command {
- t.Helper()
- root := &cobra.Command{Use: "basecamp"}
- root.PersistentFlags().Bool("json", false, "")
- root.PersistentFlags().CountP("verbose", "v", "")
- root.PersistentFlags().String("project", "", "")
- root.AddCommand(NewMCPCmd())
- root.AddCommand(&cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }})
- return root
-}
-
// A descriptor number no descriptor could have is refused where every other
// bad one is: at the read, by asking the operating system about it.
func TestABadDescriptorNumberIsRefusedAtTheRead(t *testing.T) {
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index dbc2a0758..13612abb8 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -209,3 +210,37 @@ func TestMCPCommandFlagPassthrough(t *testing.T) {
assert.False(t, strings.Contains(tools[0].Description, "create_project"),
"read-only basecamp_projects still lists a write action")
}
+
+// testRootForMCP is the command tree TakeConnectTaskToken resolves against:
+// a root carrying this command, as cli.Execute builds it.
+func testRootForMCP(t *testing.T) *cobra.Command {
+ t.Helper()
+ root := &cobra.Command{Use: "basecamp"}
+ root.PersistentFlags().Bool("json", false, "")
+ root.PersistentFlags().CountP("verbose", "v", "")
+ root.PersistentFlags().String("project", "", "")
+ root.AddCommand(NewMCPCmd())
+ root.AddCommand(&cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }})
+ return root
+}
+
+// The startup read must not touch the flags of the command that then runs:
+// pflag's slice and count values append and increment once a value has been
+// set, so sharing them would double what the command was given.
+func TestTakeConnectTaskTokenLeavesTheCommandsOwnFlagsAlone(t *testing.T) {
+ t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
+ root := testRootForMCP(t)
+ argv := []string{"-v", "mcp", "--domains", "todos,cards", "--connect-state", "/x", "--connect-token-fd", "3"}
+
+ TakeConnectTaskToken(root, argv)
+
+ target, rest, err := root.Find(argv)
+ require.NoError(t, err)
+ require.NoError(t, target.ParseFlags(rest))
+ domains, err := target.Flags().GetStringSlice("domains")
+ require.NoError(t, err)
+ assert.Equal(t, []string{"todos", "cards"}, domains, "the command sees what it was given, once")
+ verbose, err := root.PersistentFlags().GetCount("verbose")
+ require.NoError(t, err)
+ assert.Equal(t, 1, verbose)
+}
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_unix.go
index 793b50fd1..de650dc60 100644
--- a/internal/commands/mcp_token_unix.go
+++ b/internal/commands/mcp_token_unix.go
@@ -44,11 +44,15 @@ func readTaskToken(fd int) (string, error) {
if kind := st.Mode & unix.S_IFMT; kind != unix.S_IFIFO && kind != unix.S_IFSOCK {
return "", output.ErrUsage(fmt.Sprintf("descriptor %d is not a pipe or a socket; the task token is handed over on one, never from a file", fd))
}
- // Non-blocking before it is wrapped, so the runtime polls it and a read
- // deadline applies. The flag is on the open file description, so anything
- // else sharing it would see it too; the connector's bridge execs this
- // server, so nothing does.
+ // Non-blocking before it is wrapped, which is the order os.NewFile needs
+ // to hand back a pollable file, and a read deadline only applies to one.
+ // The flag is on the open file description, so anything else sharing it
+ // would see it too; the connector's bridge execs this server, so nothing
+ // does. From the moment the mode is changed the descriptor is ours, so
+ // this path closes it rather than leaving it open through the hooks that
+ // follow, where a child could inherit it.
if err := unix.SetNonblock(fd, true); err != nil {
+ _ = unix.Close(fd)
return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
}
file := os.NewFile(uintptr(fd), "connect-token")
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index 1360c393b..a75a55df2 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -129,8 +129,11 @@ import (
// returns to admitted for its one retry, or goes to blocked after a
// second failure (withdrawExposure) — the only way from dispatched to
// blocked.
-// 5. A worker acts only on its own task's rows, reports only what it was
-// handed, and a reported outcome stands.
+// 5. A worker acts only on its own task's rows, reports only what it
+// pulled, and a reported outcome stands — the report path enforces that,
+// and the delivery triggers hold the same shape for anything else writing
+// to the file: forward only, never past a missing pull, with the
+// dispatcher settling a completed record as the one exception.
// 6. A task is made only of instructions a worker can pull, and finished
// work is never handed out for the first time: a completed record is
// served, acknowledged and completed only by the worker that pulled it
@@ -796,16 +799,23 @@ func (d *TaskDispatch) Complete(ctx context.Context, eventID int64, c Completion
}
return false, fmt.Errorf("connector: event %d completed as %s: %w", eventID, te.outcome, ErrReportConflict)
}
- if _, err := d.ledger.move(ctx, tx, transition{id: eventID, state: StateCompleted, from: []RecordState{StateDispatched}}); err != nil {
- return false, err
- }
+ // The delivery first, the record after: while the record is still
+ // dispatched, task_events_exposure_comes_first reads this as a
+ // worker's completion and holds it to the pull. Completing the
+ // record first would make every completion look like the
+ // dispatcher settling one.
now := d.ledger.timestamp()
- _, err = tx.ExecContext(ctx, `
+ if _, err = tx.ExecContext(ctx, `
UPDATE task_events
SET delivery = 'completed', delivered_at = COALESCE(delivered_at, ?), completed_at = ?,
outcome = ?, links = ?, reply_id = ?
-WHERE task_id = ? AND event_id = ?`, now, now, string(c.Outcome), string(encoded), nullableID(c.ReplyID), taskID, eventID)
- return true, err
+WHERE task_id = ? AND event_id = ?`, now, now, string(c.Outcome), string(encoded), nullableID(c.ReplyID), taskID, eventID); err != nil {
+ return false, err
+ }
+ if _, err := d.ledger.move(ctx, tx, transition{id: eventID, state: StateCompleted, from: []RecordState{StateDispatched}}); err != nil {
+ return false, err
+ }
+ return true, nil
})
return err
})
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index ce755d4c8..2c847cd6d 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -1210,3 +1210,54 @@ func TestCreateTaskRefusesAnEmptyInstruction(t *testing.T) {
_, err = f.ledger.CreateTask(ctx, []int64{1})
require.ErrorIs(t, err, ErrNotDispatchable)
}
+
+// A reported outcome stands, and so does what came with it: an
+// acknowledgement arriving after the completion is refused, not written.
+func TestAnAcknowledgementAfterTheOutcomeIsRefused(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, err = f.d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ require.NoError(t, err)
+
+ late := int64(4242)
+ _, err = f.d.Ack(ctx, 1, &late)
+
+ require.ErrorIs(t, err, ErrReportConflict)
+ var ack *int64
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT ack_id FROM task_events WHERE event_id = 1`).Scan(&ack))
+ assert.Nil(t, ack, "nothing was written")
+}
+
+// A withdrawn exposure is finished: the record it belonged to may be running
+// again on a new task, and the old row does not follow it.
+func TestAWithdrawnExposureDoesNotFollowTheRetry(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = 'launch' WHERE event_id = 1`)
+ require.NoError(t, err)
+
+ tx, err := f.ledger.db.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.supersedeTask(ctx, tx, f.grant.ID))
+ require.NoError(t, f.ledger.withdrawExposure(ctx, tx, f.grant.ID, 1, StateAdmitted, ""))
+ retry, err := f.ledger.createTask(ctx, tx, []int64{1})
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit())
+
+ // The retry's worker pulls and completes it, so the record is completed.
+ d, err := f.ledger.Dispatch(ctx, retry.Token, adapterAgentID)
+ require.NoError(t, err)
+ _, _, err = d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, err = d.Complete(ctx, 1, Completion{Outcome: OutcomeSucceeded})
+ require.NoError(t, err)
+
+ // The withdrawn row on the old task stays where it was.
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'completed' WHERE task_id = ? AND event_id = 1`, f.grant.ID)
+ require.Error(t, err)
+ var delivery string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT delivery FROM task_events WHERE task_id = ? AND event_id = 1`, f.grant.ID).Scan(&delivery))
+ assert.Equal(t, "exposed", delivery)
+}
diff --git a/internal/connector/ledger_owner_unix_test.go b/internal/connector/ledger_owner_unix_test.go
new file mode 100644
index 000000000..20058dec2
--- /dev/null
+++ b/internal/connector/ledger_owner_unix_test.go
@@ -0,0 +1,43 @@
+//go:build unix
+
+package connector
+
+import (
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Ownership is read from the file itself: a ledger this user does not own is
+// not one this process may read, whoever else can see it.
+func TestOwnedByThisUser(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "ledger.db")
+ require.NoError(t, os.WriteFile(path, nil, 0o600))
+ info, err := os.Lstat(path)
+ require.NoError(t, err)
+
+ assert.True(t, ownedByThisUser(info))
+ assert.True(t, sameOwner(info, info))
+ assert.False(t, sameOwner(info, otherOwner{info}), "the owner changed since the check")
+ err = verifySameFile(path, otherOwner{info})
+ require.Error(t, err, "a second open is held to the owner the check passed")
+ assert.Contains(t, err.Error(), "no longer owned by the user the check passed")
+ assert.False(t, ownedByThisUser(otherOwner{info}), "another user's file")
+ assert.False(t, ownedByThisUser(noOwner{info}), "a file whose owner cannot be read")
+}
+
+type otherOwner struct{ os.FileInfo }
+
+func (o otherOwner) Sys() any {
+ stat := *o.FileInfo.Sys().(*syscall.Stat_t)
+ stat.Uid++
+ return &stat
+}
+
+type noOwner struct{ os.FileInfo }
+
+func (noOwner) Sys() any { return nil }
diff --git a/internal/connector/ledger_test.go b/internal/connector/ledger_test.go
index 20b8c7bca..50a1eb5d7 100644
--- a/internal/connector/ledger_test.go
+++ b/internal/connector/ledger_test.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"os"
"path/filepath"
- "syscall"
"testing"
"time"
@@ -350,33 +349,3 @@ func TestAnAliasClaimedDuringTheFirstCheckFindsTheSameFile(t *testing.T) {
assert.Same(t, first, second, "one file, one entry, whatever it is called")
}
-
-// Ownership is read from the file itself: a ledger this user does not own is
-// not one this process may read, whoever else can see it.
-func TestOwnedByThisUser(t *testing.T) {
- path := filepath.Join(t.TempDir(), "ledger.db")
- require.NoError(t, os.WriteFile(path, nil, 0o600))
- info, err := os.Lstat(path)
- require.NoError(t, err)
-
- assert.True(t, ownedByThisUser(info))
- assert.True(t, sameOwner(info, info))
- assert.False(t, sameOwner(info, otherOwner{info}), "the owner changed since the check")
- err = verifySameFile(path, otherOwner{info})
- require.Error(t, err, "a second open is held to the owner the check passed")
- assert.Contains(t, err.Error(), "no longer owned by the user the check passed")
- assert.False(t, ownedByThisUser(otherOwner{info}), "another user's file")
- assert.False(t, ownedByThisUser(noOwner{info}), "a file whose owner cannot be read")
-}
-
-type otherOwner struct{ os.FileInfo }
-
-func (o otherOwner) Sys() any {
- stat := *o.FileInfo.Sys().(*syscall.Stat_t)
- stat.Uid++
- return &stat
-}
-
-type noOwner struct{ os.FileInfo }
-
-func (noOwner) Sys() any { return nil }
diff --git a/internal/connector/owner_unix.go b/internal/connector/owner_unix.go
index a335360df..6deb424c8 100644
--- a/internal/connector/owner_unix.go
+++ b/internal/connector/owner_unix.go
@@ -12,7 +12,10 @@ import (
// where ownership is read.
func ownedByThisUser(info os.FileInfo) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
- return ok && int(stat.Uid) == os.Getuid()
+ // The effective uid, which is what setup's private-path check compares
+ // against: the two must agree, or a first open could pass where a later
+ // one is refused.
+ return ok && int(stat.Uid) == os.Geteuid()
}
// sameOwner reports two stats of a file with the same owner.
From a74d10b5c5a77d7f0c1b92ca5af0be056f076bbf Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 17:05:21 +0200
Subject: [PATCH 25/33] Keep the token descriptor from children instead of
racing to read it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every round in this area was the same shape: the startup read had to decide,
from the arguments alone, whether cobra would accept an invocation — and
whenever it guessed differently it drained a one-shot pipe for a server that
never started, or missed a spelling and read too late. The guess was never the
point. What the token needs is that no child of this process inherits it, and
that is a property of the descriptor, not of when it is read.
So startup marks the descriptor close-on-exec and takes any stale token out of
the environment, and reads nothing. The command reads the token itself, once
cobra has accepted the invocation and refused everything else — help, a bad
flag, a stray argument, a read-only server, a missing state directory. The
scan that finds the descriptor number can be loose, because marking one
close-on-exec costs nothing and touches no other process.
With it go the flag-shape copying, the positional-argument check, and the
late-read refusal, none of which are needed now.
---
internal/cli/root.go | 15 +-
internal/commands/mcp.go | 172 +++++----------
internal/commands/mcp_cloexec_other.go | 7 +
internal/commands/mcp_cloexec_unix.go | 14 ++
.../commands/mcp_connect_token_unix_test.go | 205 +++++-------------
internal/commands/mcp_test.go | 43 +---
6 files changed, 142 insertions(+), 314 deletions(-)
create mode 100644 internal/commands/mcp_cloexec_other.go
create mode 100644 internal/commands/mcp_cloexec_unix.go
diff --git a/internal/cli/root.go b/internal/cli/root.go
index cbb811c6b..a2f17be7a 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -377,13 +377,14 @@ func Execute() {
cmd.AddCommand(commands.NewMCPCmd())
cmd.AddCommand(commands.NewConnectCmd())
- // Before the command runs: a connector-started worker's task token arrives
- // on an inherited descriptor, and the root command's persistent hooks —
- // config hardening, profile loading, the update check — run before any
- // command's own RunE and may start a process that would inherit it. The
- // command tree is built by now, so which invocation this is, and which
- // descriptor it names, are cobra's answer rather than a guess.
- commands.TakeConnectTaskToken(cmd, os.Args[1:])
+ // Before anything else: a connector-started worker's task token arrives on
+ // an inherited descriptor, and the root command's persistent hooks — config
+ // hardening, profile loading, the update check — run before any command's
+ // own RunE and may start a process that would inherit it. This keeps the
+ // descriptor from those children and takes a stale token out of the
+ // environment; the token itself is read by the command, once cobra has
+ // accepted the invocation.
+ commands.PrepareConnectToken(os.Args[1:])
// Tier-2 stdin guard: reject a stray literal "-" when stdin is piped,
// everywhere a command doesn't explicitly accept it — except cobra's
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 08b968c6e..2ad8986b9 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -4,18 +4,19 @@ import (
"context"
"errors"
"fmt"
- "io"
"log/slog"
+ "math"
"os"
"os/signal"
"path/filepath"
+ "slices"
+ "strconv"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
- "github.com/spf13/pflag"
"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/connector"
@@ -32,112 +33,69 @@ var mcpTransport = func() mcp.Transport { return &mcp.StdioTransport{} }
// the server refuses to start, so nothing is led to hand it over that way.
const connectTaskTokenEnv = "BASECAMP_CONNECT_TASK_TOKEN"
-// takenTaskToken is the token TakeConnectTaskToken read, and whether it ran.
-// The descriptor is read before the command tree runs at all, so nothing this
-// process starts on the way — a config hardening pass, an update check, a
-// keychain helper — can inherit it.
-var takenTaskToken struct {
- token string
- err error
- taken bool
-}
+// connectTokenEnvRefused records that a task token was found in the
+// environment at startup. It is taken out there and refused when the server
+// would serve the connect domain: the environment is not a way in.
+var connectTokenEnvRefused bool
-// TakeConnectTaskToken reads the connector task token from the descriptor the
-// arguments name, and closes it, before anything else in the process runs.
+// PrepareConnectToken makes a connector-started worker's task token safe to
+// read later, and takes any stale token out of the environment. It runs before
+// the command tree is built, and it reads nothing.
//
-// Cobra runs the root command's persistent hooks before any command's own
-// RunE, and those hooks load configuration, tighten directories and may start
-// a background update check. A descriptor still open then is one a child could
-// inherit, so the read happens ahead of all of it. What it found — the token,
-// or the refusal — is the mcp command's to use when it runs.
+// The hazard it closes is inheritance: the root command's persistent hooks
+// load configuration, tighten directories and may start a background update
+// check, and a child started then would inherit an open descriptor. Marking
+// the descriptor close-on-exec ends that, without consuming it — so the token
+// is still there to be read by the command itself, once cobra has decided the
+// invocation is one that serves. Reading it here instead would drain a
+// one-shot pipe for every invocation cobra goes on to refuse.
//
-// Which arguments mean what is cobra's answer and pflag's, never a scan of our
-// own: root finds the command the way it will when it executes, and the same
-// flag types parse what is left. A hand-written scan reads a descriptor for an
-// invocation the command then refuses, or misses one it accepts.
-func TakeConnectTaskToken(root *cobra.Command, args []string) {
- fd, ok := connectTokenFD(root, args)
- if !ok {
- return
- }
- takenTaskToken.taken = true
- // The environment is refused before the descriptor is read, so a stale
- // token there does not cost the connector its handoff, and it is out of
- // the environment before the hooks that could pass it to a child.
+// The scan is deliberately loose, because what it does is harmless: a
+// descriptor that is not a token pipe is no worse for being close-on-exec in
+// a process that is about to serve MCP on stdio, and one that is not ours is
+// not touched, since the flag has to be there to be found.
+func PrepareConnectToken(args []string) {
if _, set := os.LookupEnv(connectTaskTokenEnv); set {
_ = os.Unsetenv(connectTaskTokenEnv)
- takenTaskToken.err = output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
- "Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
- return
+ connectTokenEnvRefused = true
+ }
+ if fd, ok := connectTokenFDArg(args); ok {
+ markCloseOnExec(fd)
}
- takenTaskToken.token, takenTaskToken.err = readTaskToken(fd)
}
-// discardedValue stands in for a flag this read does not care about: it keeps
-// the flag's shape, so the arguments parse as the command will parse them, and
-// keeps none of its value.
-type discardedValue struct{ kind string }
-
-func (discardedValue) String() string { return "" }
-func (discardedValue) Set(string) error { return nil }
-func (v discardedValue) Type() string { return v.kind }
-
-// connectStateGiven is the one rule for whether a state directory was given,
-// used by the startup read and by the command, so they never disagree about
-// an invocation.
-func connectStateGiven(state string) bool { return strings.TrimSpace(state) != "" }
-
-// connectTokenFD reports the descriptor to read: this command, serving the
-// connect domain, with a descriptor given. A read-only server serves no
-// connect domain, and a descriptor without a state directory is refused by the
-// command, so neither reads anything.
-func connectTokenFD(root *cobra.Command, args []string) (int, bool) {
- target, rest, err := root.Find(args)
- if err != nil || target == nil || target.Name() != "mcp" || target.Parent() == nil {
+// connectTokenFDArg finds a --connect-token-fd value in the arguments of an
+// mcp command. It decides nothing about the invocation: the command's own flag
+// parsing does that, and this only says which descriptor to keep from a child.
+func connectTokenFDArg(args []string) (int, bool) {
+ if !slices.Contains(args, "mcp") {
return 0, false
}
-
- // This command's own flags, on a command of its own: the definitions are
- // the real ones, so a flag it does not accept, or a value it needs and
- // does not get, fails here exactly as it will there — and nothing the
- // command will actually run is touched, because binding to the live
- // command's flags would set its values and count its counters twice.
- // The root's flags are unknown here and are skipped rather than guessed.
- flags := NewMCPCmd().Flags()
- flags.Init("mcp", pflag.ContinueOnError)
- flags.SetOutput(io.Discard)
- flags.BoolP("help", "h", false, "")
- // The root's flags may appear anywhere, and what they are is the root's
- // business: each is copied by shape alone — name, shorthand, and whether
- // it takes a value — onto a value that keeps nothing. So this parse
- // accepts exactly what the command will accept, and a flag neither of
- // them knows is refused here as it will be there.
- target.Root().PersistentFlags().VisitAll(func(f *pflag.Flag) {
- if flags.Lookup(f.Name) != nil {
- return
+ for i, arg := range args {
+ value, isFlag := strings.CutPrefix(arg, "--connect-token-fd")
+ switch {
+ case !isFlag:
+ continue
+ case strings.HasPrefix(value, "="):
+ value = value[1:]
+ case value != "":
+ continue
+ case i+1 < len(args):
+ value = args[i+1]
+ default:
+ continue
+ }
+ if fd, err := strconv.ParseInt(value, 0, 64); err == nil && fd >= 3 && fd <= math.MaxInt32 {
+ return int(fd), true
}
- copied := flags.VarPF(discardedValue{kind: f.Value.Type()}, f.Name, f.Shorthand, f.Usage)
- copied.NoOptDefVal = f.NoOptDefVal
- })
- if err := flags.Parse(rest); err != nil {
- return 0, false
- }
- if flags.NArg() > 0 {
- return 0, false // cobra.NoArgs refuses it
- }
- if help, _ := flags.GetBool("help"); help {
- return 0, false // cobra prints help and serves nothing
- }
-
- readOnly, _ := flags.GetBool("read-only")
- state, _ := flags.GetString("connect-state")
- fd, err := flags.GetInt("connect-token-fd")
- if err != nil || readOnly || !connectStateGiven(state) || !flags.Changed("connect-token-fd") {
- return 0, false
}
- return fd, true
+ return 0, false
}
+// connectStateGiven is the one rule for whether a state directory was given,
+// so the startup step and the command never disagree about an invocation.
+func connectStateGiven(state string) bool { return strings.TrimSpace(state) != "" }
+
// maxTaskTokenBytes bounds what is read from the token descriptor. A token is
// 43 characters; anything near this is not one.
const maxTaskTokenBytes = 4096
@@ -186,10 +144,10 @@ func NewMCPCmd() *cobra.Command {
// anything else runs: authentication can start helper processes,
// and a child started then would inherit an open descriptor.
var taskToken string
- // A token in the environment is taken out and refused whatever
- // the flags: it is not a way in for any server.
- if _, set := os.LookupEnv(connectTaskTokenEnv); set {
- _ = os.Unsetenv(connectTaskTokenEnv)
+ // A token in the environment was taken out at startup, before the
+ // hooks that could have passed it to a child. It is refused here:
+ // the environment is not a way in for any server.
+ if connectTokenEnvRefused {
return output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
"Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
}
@@ -202,7 +160,10 @@ func NewMCPCmd() *cobra.Command {
// the token or the ledger is touched.
return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
}
- token, err := connectTaskToken(connectTokenFD)
+ // Read here, once cobra has accepted the invocation: the
+ // descriptor has been close-on-exec since startup, so nothing
+ // the hooks started could have inherited it.
+ token, err := readTaskToken(connectTokenFD)
if err != nil {
return err
}
@@ -283,19 +244,6 @@ func stateDirHint(refusal *connector.StateDirError) string {
// agent's id comes from, and a ledger for another account is refused rather
// than served. The ledger must already exist — a worker's server reads the
// connector's ledger, it never starts one.
-// connectTaskToken is what TakeConnectTaskToken read before the command tree
-// ran. Nothing reads the descriptor here: by now the persistent hooks have
-// run, and a descriptor still open through them is one a child could have
-// inherited. A server whose token was not taken at startup does not start.
-func connectTaskToken(fd int) (string, error) {
- if takenTaskToken.taken {
- return takenTaskToken.token, takenTaskToken.err
- }
- if fd >= 0 {
- return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d was not read at startup; the token descriptor is read before anything else runs", fd))
- }
- return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
-}
func openConnectDispatch(ctx context.Context, stateDir, accountID, token string) (*connector.TaskDispatch, func(), error) {
diff --git a/internal/commands/mcp_cloexec_other.go b/internal/commands/mcp_cloexec_other.go
new file mode 100644
index 000000000..24c0cb3d0
--- /dev/null
+++ b/internal/commands/mcp_cloexec_other.go
@@ -0,0 +1,7 @@
+//go:build !unix
+
+package commands
+
+// markCloseOnExec has nothing to do where the connector does not run: the
+// command refuses --connect-state there.
+func markCloseOnExec(int) {}
diff --git a/internal/commands/mcp_cloexec_unix.go b/internal/commands/mcp_cloexec_unix.go
new file mode 100644
index 000000000..d1c86c483
--- /dev/null
+++ b/internal/commands/mcp_cloexec_unix.go
@@ -0,0 +1,14 @@
+//go:build unix
+
+package commands
+
+import "golang.org/x/sys/unix"
+
+// markCloseOnExec keeps fd out of the processes this one starts. Best effort:
+// a descriptor that is not open, or not ours, is nothing to protect, and the
+// command refuses it when it tries to read the token from it.
+func markCloseOnExec(fd int) {
+ if flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil {
+ _, _ = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags|unix.FD_CLOEXEC)
+ }
+}
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 3b7cca86d..401fbc0ca 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -4,7 +4,6 @@ package commands
import (
"bytes"
- "context"
"io/fs"
"os"
"path/filepath"
@@ -14,7 +13,7 @@ import (
"testing"
"time"
- "github.com/basecamp/basecamp-cli/internal/appctx"
+ "golang.org/x/sys/unix"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -191,185 +190,81 @@ func TestMCPCommandDoesNotWaitOnAWriteEndLeftOpen(t *testing.T) {
assert.Less(t, time.Since(started), 5*time.Second)
}
-// The token is read before the command tree runs at all: Cobra's root
-// persistent hooks load config, tighten directories and may start an update
-// check, and a descriptor still open then is one a child could inherit.
-func TestTakeConnectTaskTokenReadsBeforeTheCommandTree(t *testing.T) {
- fd := tokenPipe(t, "a-task-token\n")
- dev, ino, _ := fdIdentity(t, fd)
- t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
-
- TakeConnectTaskToken(testRootForMCP(t), []string{"mcp", "--connect-state", "/somewhere", "--connect-token-fd", strconv.Itoa(fd)})
-
- require.True(t, takenTaskToken.taken)
- require.NoError(t, takenTaskToken.err)
- assert.Equal(t, "a-task-token", takenTaskToken.token)
- if nowDev, nowIno, open := fdIdentity(t, fd); open {
- assert.False(t, nowDev == dev && nowIno == ino, "the descriptor is closed already")
- }
-}
-
-func TestTakeConnectTaskTokenIgnoresEverythingElse(t *testing.T) {
- t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
- for name, args := range map[string][]string{
- "another command": {"search", "list", "--connect-token-fd", "3"},
- "no flag": {"mcp", "--connect-state", "/x"},
- "a flag that starts the same": {"mcp", "--connect-state", "/x", "--connect-token-fdx", "3"},
- "no state directory": {"mcp", "--connect-token-fd", "3"},
- "read-only": {"mcp", "--connect-state", "/x", "--read-only", "--connect-token-fd", "3"},
- "read-only, spelled out": {"mcp", "--connect-state", "/x", "--read-only=true", "--connect-token-fd", "3"},
- "nothing after it": {"mcp", "--connect-state", "/x", "--connect-token-fd"},
- } {
- t.Run(name, func(t *testing.T) {
- takenTaskToken.taken = false
- TakeConnectTaskToken(testRootForMCP(t), args)
- assert.False(t, takenTaskToken.taken, "left to Cobra and the command to report")
- })
- }
-
- // Both spellings of the flag are read, with the state directory given.
- fd := tokenPipe(t, "token\n")
- takenTaskToken.taken = false
- TakeConnectTaskToken(testRootForMCP(t), []string{"mcp", "--connect-state", "/x", "--connect-token-fd=" + strconv.Itoa(fd)})
- require.True(t, takenTaskToken.taken)
- assert.Equal(t, "token", takenTaskToken.token)
+// A descriptor number no descriptor could have is refused where every other
+// bad one is: at the read, by asking the operating system about it.
+func TestABadDescriptorNumberIsRefusedAtTheRead(t *testing.T) {
+ _, err := readTaskToken(99999999)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not open")
}
-// A server whose token was not taken at startup does not read the descriptor
-// late: by then the root hooks have run, and a descriptor still open through
-// them is one a child could have inherited. It refuses instead.
-func TestTheMCPCommandRefusesATokenNotTakenAtStartup(t *testing.T) {
+// And the command reads a whitespace state directory as absent as well, so it
+// refuses the descriptor rather than reporting a token that was never read.
+func TestABlankStateDirectoryIsNoStateDirectory(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "test-token")
app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
- dir, grant, _ := connectStateWithTask(t)
- fd := tokenPipe(t, grant.Token+"\n")
+ fd := tokenPipe(t, "token\n")
dev, ino, _ := fdIdentity(t, fd)
- // The command on its own, as if startup had not scanned the arguments.
- cmd := NewMCPCmd()
- cmd.SetArgs([]string{"--connect-state", dir, "--connect-token-fd", strconv.Itoa(fd)})
- cmd.SetContext(appctx.WithApp(context.Background(), app))
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- err := cmd.Execute()
-
+ err := executeMCPCommand(t, app, "--connect-state", " ", "--connect-token-fd", strconv.Itoa(fd))
require.Error(t, err)
- assert.Contains(t, err.Error(), "was not read at startup")
+ assert.Contains(t, err.Error(), "--connect-token-fd is only for a server started with --connect-state")
nowDev, nowIno, open := fdIdentity(t, fd)
- assert.True(t, open && nowDev == dev && nowIno == ino, "and it does not read the descriptor now")
+ assert.True(t, open && nowDev == dev && nowIno == ino, "and the descriptor was not touched")
}
-// The startup scan and the flag parser must read --connect-token-fd the same
-// way. Where they disagree, one descriptor is drained and closed while the
-// command serves from another — or the scan misses a spelling and the read
-// falls to a point where a child could already have inherited it.
-func TestTheTokenPreScanAgreesWithTheFlagParser(t *testing.T) {
- for _, argv := range [][]string{
- {"mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd=3"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd=0x3"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd=010"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--connect-token-fd", "4"},
- {"mcp", "--connect-state=/x", "--connect-token-fd=3", "--connect-token-fd=4"},
- {"--json", "mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
- {"-v", "mcp", "--connect-state", "/x", "--connect-token-fd", "5"},
- {"mcp", "--connect-state", "/x", "--connect-token-fdx", "3"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd", "three"},
- {"mcp", "--connect-state", "/x", "--connect-token-fd"},
- {"mcp", "--read-only"},
- } {
- t.Run(strings.Join(argv, " "), func(t *testing.T) {
- scanned, found := connectTokenFD(testRootForMCP(t), argv)
-
- // What the command itself will see, from the flags it declares.
- root := testRootForMCP(t)
- target, rest, err := root.Find(argv)
- require.NoError(t, err)
- flags := target.Flags()
- flags.AddFlagSet(root.PersistentFlags())
- parseErr := flags.Parse(rest)
- readOnly, _ := flags.GetBool("read-only")
- state, _ := flags.GetString("connect-state")
- parsed, _ := flags.GetInt("connect-token-fd")
- wants := parseErr == nil && target.Name() == "mcp" && !readOnly &&
- state != "" && flags.Changed("connect-token-fd")
+// Startup keeps the token descriptor out of anything the process starts, and
+// reads nothing: the command reads it once cobra has accepted the invocation,
+// so a one-shot pipe is never drained for a run that never serves.
+func TestPrepareConnectTokenMarksTheDescriptorCloseOnExec(t *testing.T) {
+ fd := tokenPipe(t, "a-task-token\n")
+ before, err := fcntlGetFD(fd)
+ require.NoError(t, err)
+ require.Zero(t, before&unix.FD_CLOEXEC, "inherited descriptors arrive without it")
- if !wants {
- assert.False(t, found, "the scan read a descriptor this invocation would not")
- return
- }
- require.True(t, found, "the command will read a descriptor the scan missed")
- assert.Equal(t, parsed, scanned)
- })
- }
-}
+ PrepareConnectToken([]string{"mcp", "--connect-state", "/x", "--connect-token-fd", strconv.Itoa(fd)})
-// A bare -- ends the flags for pflag, so nothing after it is a descriptor to
-// read: cobra.NoArgs then refuses the command outright.
-func TestTheTokenPreScanStopsAtADoubleDash(t *testing.T) {
- _, found := connectTokenFD(testRootForMCP(t), []string{"mcp", "--connect-state", "/x", "--", "--connect-token-fd", "3"})
- assert.False(t, found)
+ after, err := fcntlGetFD(fd)
+ require.NoError(t, err)
+ assert.NotZero(t, after&unix.FD_CLOEXEC, "no child of this process inherits it")
+ assert.True(t, fdOpen(fd), "and it is still there for the command to read")
}
-// "mcp" has to be the command, not a word somewhere in the arguments, and a
-// read-only server reads no token: it serves no connect domain.
-func TestTheTokenPreScanReadsOnlyThisCommandsDescriptor(t *testing.T) {
+func TestPrepareConnectTokenLooksOnlyWhereItShould(t *testing.T) {
+ fd := tokenPipe(t, "token\n")
for name, args := range map[string][]string{
- "another command's argument": {"search", "--", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
- "a query that says mcp": {"search", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"},
- "a root bool flag before it": {"--json", "search", "mcp", "--connect-token-fd", "3"},
- "read-only": {"mcp", "--connect-state", "/x", "--read-only", "--connect-token-fd", "3"},
- "read-only as a value": {"mcp", "--connect-state", "/x", "--read-only=1", "--connect-token-fd", "3"},
- "a descriptor past a --": {"mcp", "--connect-state", "/x", "--", "--connect-token-fd", "3"},
- "no state directory": {"mcp", "--connect-token-fd", "3"},
+ "another command": {"search", "--connect-token-fd", strconv.Itoa(fd)},
+ "no flag": {"mcp", "--connect-state", "/x"},
+ "standard input": {"mcp", "--connect-token-fd", "0"},
+ "not a number": {"mcp", "--connect-token-fd", "three"},
+ "nothing after": {"mcp", "--connect-token-fd"},
} {
t.Run(name, func(t *testing.T) {
- _, found := connectTokenFD(testRootForMCP(t), args)
+ _, found := connectTokenFDArg(args)
assert.False(t, found)
})
}
- fd, found := connectTokenFD(testRootForMCP(t), []string{"--json", "mcp", "--connect-state", "/x", "--connect-token-fd", "3"})
- require.True(t, found, "a root flag before the command is the root's, not a value")
- assert.Equal(t, 3, fd)
-}
-
-// A descriptor number no descriptor could have is refused where every other
-// bad one is: at the read, by asking the operating system about it.
-func TestABadDescriptorNumberIsRefusedAtTheRead(t *testing.T) {
- _, err := readTaskToken(99999999)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "not open")
-}
-
-// Nothing is read for an invocation that serves nothing: help, a flag the
-// command does not accept, or a state directory that is only whitespace —
-// which the command reads as absent too, so the two never disagree.
-func TestNothingIsReadForAnInvocationThatServesNothing(t *testing.T) {
for name, args := range map[string][]string{
- "help": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--help"},
- "help, short": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "-h"},
- "a flag it refuses": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--bogus"},
- "a missing value": {"mcp", "--connect-state", "/x", "--connect-token-fd", "3", "--domains"},
- "blank state": {"mcp", "--connect-state", " ", "--connect-token-fd", "3"},
+ "a value of its own": {"mcp", "--connect-token-fd", strconv.Itoa(fd)},
+ "joined with an =": {"mcp", "--connect-token-fd=" + strconv.Itoa(fd)},
+ "after a root flag": {"--json", "mcp", "--connect-token-fd", strconv.Itoa(fd)},
} {
t.Run(name, func(t *testing.T) {
- _, found := connectTokenFD(testRootForMCP(t), args)
- assert.False(t, found)
+ got, found := connectTokenFDArg(args)
+ require.True(t, found)
+ assert.Equal(t, fd, got)
})
}
}
-// And the command reads a whitespace state directory as absent as well, so it
-// refuses the descriptor rather than reporting a token that was never read.
-func TestABlankStateDirectoryIsNoStateDirectory(t *testing.T) {
- t.Setenv("BASECAMP_TOKEN", "test-token")
- app := setupMCPTestApp(t, "999", "https://3.basecampapi.com")
- fd := tokenPipe(t, "token\n")
- dev, ino, _ := fdIdentity(t, fd)
+// A stale token in the environment is taken out at startup, before the hooks
+// that could pass it to a child, and the command then refuses to serve.
+func TestPrepareConnectTokenTakesAStaleEnvironmentTokenOut(t *testing.T) {
+ t.Setenv("BASECAMP_CONNECT_TASK_TOKEN", "stale")
+ t.Cleanup(func() { connectTokenEnvRefused = false })
- err := executeMCPCommand(t, app, "--connect-state", " ", "--connect-token-fd", strconv.Itoa(fd))
- require.Error(t, err)
- assert.Contains(t, err.Error(), "--connect-token-fd is only for a server started with --connect-state")
- nowDev, nowIno, open := fdIdentity(t, fd)
- assert.True(t, open && nowDev == dev && nowIno == ino, "and the descriptor was not touched")
+ PrepareConnectToken([]string{"mcp", "--connect-state", "/x"})
+
+ assert.Empty(t, os.Getenv("BASECAMP_CONNECT_TASK_TOKEN"))
+ assert.True(t, connectTokenEnvRefused)
}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index 13612abb8..8416e3ad3 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
- "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -52,11 +51,9 @@ func setupMCPTestApp(t *testing.T, accountID, baseURL string) *appctx.App {
func executeMCPCommand(t *testing.T, app *appctx.App, args ...string) error {
t.Helper()
// As cli.Execute does, before the command tree runs at all.
- takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
- TakeConnectTaskToken(testRootForMCP(t), append([]string{"mcp"}, args...))
- t.Cleanup(func() {
- takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil
- })
+ connectTokenEnvRefused = false
+ PrepareConnectToken(append([]string{"mcp"}, args...))
+ t.Cleanup(func() { connectTokenEnvRefused = false })
cmd := NewMCPCmd()
cmd.SetArgs(args)
cmd.SetContext(appctx.WithApp(context.Background(), app))
@@ -210,37 +207,3 @@ func TestMCPCommandFlagPassthrough(t *testing.T) {
assert.False(t, strings.Contains(tools[0].Description, "create_project"),
"read-only basecamp_projects still lists a write action")
}
-
-// testRootForMCP is the command tree TakeConnectTaskToken resolves against:
-// a root carrying this command, as cli.Execute builds it.
-func testRootForMCP(t *testing.T) *cobra.Command {
- t.Helper()
- root := &cobra.Command{Use: "basecamp"}
- root.PersistentFlags().Bool("json", false, "")
- root.PersistentFlags().CountP("verbose", "v", "")
- root.PersistentFlags().String("project", "", "")
- root.AddCommand(NewMCPCmd())
- root.AddCommand(&cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }})
- return root
-}
-
-// The startup read must not touch the flags of the command that then runs:
-// pflag's slice and count values append and increment once a value has been
-// set, so sharing them would double what the command was given.
-func TestTakeConnectTaskTokenLeavesTheCommandsOwnFlagsAlone(t *testing.T) {
- t.Cleanup(func() { takenTaskToken.taken, takenTaskToken.token, takenTaskToken.err = false, "", nil })
- root := testRootForMCP(t)
- argv := []string{"-v", "mcp", "--domains", "todos,cards", "--connect-state", "/x", "--connect-token-fd", "3"}
-
- TakeConnectTaskToken(root, argv)
-
- target, rest, err := root.Find(argv)
- require.NoError(t, err)
- require.NoError(t, target.ParseFlags(rest))
- domains, err := target.Flags().GetStringSlice("domains")
- require.NoError(t, err)
- assert.Equal(t, []string{"todos", "cards"}, domains, "the command sees what it was given, once")
- verbose, err := root.PersistentFlags().GetCount("verbose")
- require.NoError(t, err)
- assert.Equal(t, 1, verbose)
-}
From 0e493b01defd9a80c5c6c5654cce71c0bc04f60e Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 17:08:44 +0200
Subject: [PATCH 26/33] Vet the whole path on a later open, without opening the
ledger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A second open rechecked the file and the last directory's mode, but not the
chain above it: a path later redirected through a writable or foreign-owned
ancestor was accepted as the one the first check passed. setup.CheckPrivateDir
walks the ancestors and the directory with the same rules CheckPrivateFile
uses, and opens nothing inside — which is the one thing this path must not do
while another handle holds the file.
---
internal/connector/ledger.go | 14 ++++++++---
internal/connector/ledger_owner_unix_test.go | 25 ++++++++++++++++++++
internal/connector/setup/private_state.go | 21 ++++++++++++++++
3 files changed, 57 insertions(+), 3 deletions(-)
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 2a8991bba..1e87f804e 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -425,12 +425,20 @@ func verifySameFile(path string, checked os.FileInfo) error {
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return fmt.Errorf("connector: secure the ledger: %s can be read by other users (mode %04o)", path, perm)
}
- dir, err := os.Lstat(filepath.Dir(path))
+ // The whole chain, not only the last directory: a path later redirected
+ // through a writable or foreign-owned ancestor is not the path the first
+ // check passed. Directories are vetted without opening the ledger, which
+ // is the one thing this path must not do.
+ dir := filepath.Dir(path)
+ if err := setup.CheckPrivateDir(dir); err != nil {
+ return fmt.Errorf("connector: secure the ledger: %w", err)
+ }
+ info, err = os.Lstat(dir)
if err != nil {
return fmt.Errorf("connector: inspect ledger directory: %w", err)
}
- if perm := dir.Mode().Perm(); perm&0o077 != 0 {
- return fmt.Errorf("connector: ledger directory %s is readable by other users (mode %04o); it must be 0700", filepath.Dir(path), perm)
+ if perm := info.Mode().Perm(); perm&0o077 != 0 {
+ return fmt.Errorf("connector: ledger directory %s is readable by other users (mode %04o); it must be 0700", dir, perm)
}
return nil
}
diff --git a/internal/connector/ledger_owner_unix_test.go b/internal/connector/ledger_owner_unix_test.go
index 20058dec2..83e9afe92 100644
--- a/internal/connector/ledger_owner_unix_test.go
+++ b/internal/connector/ledger_owner_unix_test.go
@@ -3,6 +3,7 @@
package connector
import (
+ "context"
"os"
"path/filepath"
"syscall"
@@ -41,3 +42,27 @@ func (o otherOwner) Sys() any {
type noOwner struct{ os.FileInfo }
func (noOwner) Sys() any { return nil }
+
+// A second open vets the path it is reached through, not only the file: a
+// directory chain that is no longer private is refused, without the ledger
+// being opened again.
+func TestASecondOpenVetsTheDirectoryChain(t *testing.T) {
+ home := t.TempDir()
+ dir := filepath.Join(home, "state")
+ require.NoError(t, os.Mkdir(dir, 0o700))
+ path := filepath.Join(dir, "connector.db")
+ first, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer first.Close()
+ checks := securePathRuns.Load()
+
+ // An ancestor anyone can write is a path anyone can redirect.
+ require.NoError(t, os.Chmod(home, 0o777))
+ t.Cleanup(func() { _ = os.Chmod(home, 0o700) })
+
+ _, err = OpenExistingLedger(context.Background(), path)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "secure the ledger")
+ assert.Equal(t, checks, securePathRuns.Load(), "and the file was not opened to find that out")
+}
diff --git a/internal/connector/setup/private_state.go b/internal/connector/setup/private_state.go
index 109232cc8..e24d3c152 100644
--- a/internal/connector/setup/private_state.go
+++ b/internal/connector/setup/private_state.go
@@ -147,6 +147,27 @@ func EnsurePrivateFile(path string) error {
return checkPrivateReadableFile(f, path)
}
+// CheckPrivateDir holds a directory to the rules a private file's directory
+// must meet — every ancestor this user's own and unwritable by anyone else,
+// and the directory itself private — without opening anything inside it.
+//
+// It exists for a second open of a file this process already holds: the file
+// must not be opened again (POSIX drops a process's locks on any close of it),
+// but the path it is reached through can still be vetted.
+func CheckPrivateDir(dir string) error {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return err
+ }
+ if err := checkAncestors(filepath.Dir(abs)); err != nil {
+ return err
+ }
+ if _, err := os.Lstat(abs); err != nil {
+ return fmt.Errorf("inspect %s: %w", abs, err)
+ }
+ return checkPrivateDir(abs)
+}
+
// CheckPrivateFile holds an existing file to EnsurePrivateFile's rules without
// creating anything: every directory on the way must be this user's alone, the
// file must not be a symlink, and — inspected through the open descriptor — it
From 44969920a7efa2abb1f1cff709c526c9920ea06f Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 17:17:44 +0200
Subject: [PATCH 27/33] Say why the descriptor number converts safely
The Lint gate's gosec reads the conversion on its own and cannot see that
connectTokenFDArg hands over a descriptor in [3, math.MaxInt32]. Convert once,
with the bound named at the site, the way the other accepted G115 sites in the
tree do.
---
internal/commands/mcp_cloexec_unix.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/internal/commands/mcp_cloexec_unix.go b/internal/commands/mcp_cloexec_unix.go
index d1c86c483..17c6497ae 100644
--- a/internal/commands/mcp_cloexec_unix.go
+++ b/internal/commands/mcp_cloexec_unix.go
@@ -8,7 +8,8 @@ import "golang.org/x/sys/unix"
// a descriptor that is not open, or not ours, is nothing to protect, and the
// command refuses it when it tries to read the token from it.
func markCloseOnExec(fd int) {
- if flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil {
- _, _ = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags|unix.FD_CLOEXEC)
+ handle := uintptr(fd) //nolint:gosec // G115: connectTokenFDArg only reports a descriptor in [3, math.MaxInt32], so this cannot wrap
+ if flags, err := unix.FcntlInt(handle, unix.F_GETFD, 0); err == nil {
+ _, _ = unix.FcntlInt(handle, unix.F_SETFD, flags|unix.FD_CLOEXEC)
}
}
From 1bac99ff60b937ebad2c5b5cd15fdc619f0feaf7 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Thu, 17 Sep 2026 17:37:02 +0200
Subject: [PATCH 28/33] Seal inherited descriptors at startup instead of
scanning argv
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The startup step had to find --connect-token-fd in raw arguments before cobra
parsed them, which meant a second, looser reading of the command line that
could disagree with the command's own: flag shapes, whitespace values, "mcp"
anywhere in argv, a descriptor pre-read for invocations that never serve. Each
case was a fix and the next case was waiting.
There is nothing in the descriptor's number that startup needs to know. A
descriptor this process inherited belongs to this process, not to the children
it starts, so the program's first act is to mark every one of them
close-on-exec — no flag, no argument, no environment variable, nothing to
disagree with. The token is read by the command that wants it, after parsing,
and a token in the environment is refused there too.
internal/sysfd carries a descriptor between the places that name one: a flag
value, a uintptr Go hands back, and the number a syscall wrapper takes. The
bounds live in Parse and Of, so the conversions elsewhere stop being each
caller's problem. Card 18's bridge and token socket are the other two sites.
---
.surface | 2 +-
internal/cli/inherited_fds_linux.go | 67 ++++++++++
internal/cli/inherited_fds_linux_test.go | 126 ++++++++++++++++++
internal/cli/inherited_fds_other.go | 8 ++
internal/cli/root.go | 16 +--
internal/commands/mcp.go | 106 +++++----------
internal/commands/mcp_cloexec_other.go | 7 -
internal/commands/mcp_cloexec_unix.go | 15 ---
.../commands/mcp_connect_token_unix_test.go | 62 +--------
internal/commands/mcp_test.go | 4 -
internal/commands/mcp_token_other.go | 7 +-
internal/commands/mcp_token_unix.go | 21 +--
internal/sysfd/sysfd.go | 60 +++++++++
internal/sysfd/sysfd_test.go | 88 ++++++++++++
14 files changed, 412 insertions(+), 177 deletions(-)
create mode 100644 internal/cli/inherited_fds_linux.go
create mode 100644 internal/cli/inherited_fds_linux_test.go
create mode 100644 internal/cli/inherited_fds_other.go
delete mode 100644 internal/commands/mcp_cloexec_other.go
delete mode 100644 internal/commands/mcp_cloexec_unix.go
create mode 100644 internal/sysfd/sysfd.go
create mode 100644 internal/sysfd/sysfd_test.go
diff --git a/.surface b/.surface
index 7234198be..514cca239 100644
--- a/.surface
+++ b/.surface
@@ -11043,7 +11043,7 @@ FLAG basecamp mcp --account type=string
FLAG basecamp mcp --agent type=bool
FLAG basecamp mcp --cache-dir type=string
FLAG basecamp mcp --connect-state type=string
-FLAG basecamp mcp --connect-token-fd type=int
+FLAG basecamp mcp --connect-token-fd type=string
FLAG basecamp mcp --count type=bool
FLAG basecamp mcp --domains type=stringSlice
FLAG basecamp mcp --help type=bool
diff --git a/internal/cli/inherited_fds_linux.go b/internal/cli/inherited_fds_linux.go
new file mode 100644
index 000000000..020247ef7
--- /dev/null
+++ b/internal/cli/inherited_fds_linux.go
@@ -0,0 +1,67 @@
+//go:build linux
+
+package cli
+
+import (
+ "math"
+ "os"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/basecamp/basecamp-cli/internal/sysfd"
+)
+
+// sealInheritedDescriptors keeps every descriptor this process inherited out
+// of the processes it starts, by marking each one close-on-exec.
+//
+// It decides nothing. No flag, no argument and no environment variable says
+// which descriptor is which, so there is nothing here that can disagree with
+// the command cobra goes on to run: a connector-started worker is handed its
+// task token on an inherited pipe, and by the time the command reads it, the
+// descriptor has been out of reach of any child since the first line of the
+// program — including the children the root command's persistent hooks may
+// start while loading configuration or checking for an update.
+//
+// Marking a descriptor close-on-exec neither closes it nor disturbs what is
+// buffered in it, so nothing is consumed for an invocation that never serves.
+// Standard input, output and error are left alone: a child is meant to share
+// those.
+func sealInheritedDescriptors() {
+ if err := unix.CloseRange(firstInheritedFD, math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err == nil {
+ return
+ }
+ // Kernels before 5.11 do not know CLOSE_RANGE_CLOEXEC. Ask the process
+ // which descriptors it actually has and mark those.
+ sealListedDescriptors()
+}
+
+// firstInheritedFD is the first descriptor that is not one of the standard
+// three.
+const firstInheritedFD = 3
+
+func sealListedDescriptors() {
+ dir, err := os.Open("/proc/self/fd")
+ if err != nil {
+ return
+ }
+ defer func() { _ = dir.Close() }()
+ names, err := dir.Readdirnames(-1)
+ if err != nil {
+ return
+ }
+ listing, err := sysfd.Of(dir.Fd())
+ if err != nil {
+ return
+ }
+ for _, name := range names {
+ fd, err := sysfd.Parse(name)
+ if err != nil || fd.Int() < firstInheritedFD || fd == listing {
+ continue
+ }
+ flags, err := unix.FcntlInt(fd.Uintptr(), unix.F_GETFD, 0)
+ if err != nil {
+ continue
+ }
+ _, _ = unix.FcntlInt(fd.Uintptr(), unix.F_SETFD, flags|unix.FD_CLOEXEC)
+ }
+}
diff --git a/internal/cli/inherited_fds_linux_test.go b/internal/cli/inherited_fds_linux_test.go
new file mode 100644
index 000000000..32a0cbcf4
--- /dev/null
+++ b/internal/cli/inherited_fds_linux_test.go
@@ -0,0 +1,126 @@
+//go:build linux
+
+package cli
+
+import (
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+// standardDescriptors are stdin, stdout and stderr by number, written out
+// here so these tests do not agree with the code they check about which
+// descriptors those are.
+var standardDescriptors = []int{0, 1, 2}
+
+// The descriptor number is high on purpose: a child picks the lowest free one
+// for its own files, so a low number could be in a listing for a reason that
+// has nothing to do with inheritance.
+const inheritedFD = 200
+
+// An inherited descriptor does not reach the processes this one starts.
+func TestSealedDescriptorsDoNotReachChildren(t *testing.T) {
+ shell, err := exec.LookPath("sh")
+ if err != nil {
+ t.Skip("no shell to start a child with")
+ }
+ inherit(t, inheritedFD)
+
+ sealInheritedDescriptors()
+
+ out, err := exec.CommandContext(t.Context(), shell, "-c", "ls /proc/self/fd").Output() //nolint:gosec // G204: a listing of the child's own descriptors
+ require.NoError(t, err)
+ assert.NotContains(t, strings.Fields(string(out)), "200", "the child inherited the descriptor")
+ assert.True(t, fdIsOpen(inheritedFD), "and this process still has it")
+}
+
+// It is marked, not closed, and nothing buffered in it is consumed: the
+// command that the descriptor is for still reads what was written to it.
+func TestSealingLeavesTheDescriptorReadable(t *testing.T) {
+ inherit(t, inheritedFD)
+
+ sealInheritedDescriptors()
+
+ flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ assert.NotZero(t, flags&unix.FD_CLOEXEC)
+ buffer := make([]byte, len("a-task-token\n"))
+ read, err := unix.Read(inheritedFD, buffer)
+ require.NoError(t, err)
+ assert.Equal(t, "a-task-token\n", string(buffer[:read]))
+}
+
+// Kernels before 5.11 have no CLOSE_RANGE_CLOEXEC, and the walk that stands in
+// for it there is the same promise.
+func TestSealingWithoutCloseRange(t *testing.T) {
+ inherit(t, inheritedFD)
+
+ sealListedDescriptors()
+
+ flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ assert.NotZero(t, flags&unix.FD_CLOEXEC)
+}
+
+// Standard input, output and error are a child's to share.
+func TestSealingLeavesTheStandardDescriptors(t *testing.T) {
+ shareStandardDescriptors(t)
+ before := standardFlags(t)
+
+ sealInheritedDescriptors()
+
+ assert.Equal(t, before, standardFlags(t))
+}
+
+// inherit puts a readable pipe at fd, the way an exec'd child receives one:
+// open, with no close-on-exec flag of its own.
+func inherit(t *testing.T, fd int) {
+ t.Helper()
+ reader, writer, err := os.Pipe()
+ require.NoError(t, err)
+ _, err = writer.WriteString("a-task-token\n")
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ t.Cleanup(func() { _ = reader.Close() })
+
+ require.NoError(t, unix.Dup3(int(reader.Fd()), fd, 0))
+ t.Cleanup(func() { _ = unix.Close(fd) })
+ flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ require.Zero(t, flags&unix.FD_CLOEXEC, "an inherited descriptor arrives without it")
+}
+
+func fdIsOpen(fd int) bool {
+ _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
+ return err == nil
+}
+
+// shareStandardDescriptors puts stdin, stdout and stderr in the state a child
+// inherits them in, so the test is about what sealing leaves alone rather than
+// about how the test binary happened to be started.
+func shareStandardDescriptors(t *testing.T) {
+ t.Helper()
+ for _, fd := range standardDescriptors {
+ flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ _, err = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags&^unix.FD_CLOEXEC)
+ require.NoError(t, err)
+ t.Cleanup(func() { _, _ = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags) })
+ }
+}
+
+func standardFlags(t *testing.T) []int {
+ t.Helper()
+ flags := make([]int, 0, len(standardDescriptors))
+ for _, fd := range standardDescriptors {
+ got, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ flags = append(flags, got)
+ }
+ return flags
+}
diff --git a/internal/cli/inherited_fds_other.go b/internal/cli/inherited_fds_other.go
new file mode 100644
index 000000000..befa67b61
--- /dev/null
+++ b/internal/cli/inherited_fds_other.go
@@ -0,0 +1,8 @@
+//go:build !linux
+
+package cli
+
+// sealInheritedDescriptors has nothing to seal where the handover it protects
+// does not happen: the agent connector runs on Linux, and elsewhere this keeps
+// the program building and starting the same way.
+func sealInheritedDescriptors() {}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index a2f17be7a..65a76622e 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -303,6 +303,13 @@ func postRunNoticesEnabled(app *appctx.App) bool {
// Execute runs the root command.
func Execute() {
+ // Before anything else: a descriptor this process inherited belongs to
+ // this process, not to the children it starts. The root command's
+ // persistent hooks load configuration, tighten directories and may check
+ // for an update before any command's own RunE runs, and a connector-
+ // started worker arrives holding its task token on an inherited pipe.
+ sealInheritedDescriptors()
+
cmd := NewRootCmd()
// Add subcommands
@@ -377,15 +384,6 @@ func Execute() {
cmd.AddCommand(commands.NewMCPCmd())
cmd.AddCommand(commands.NewConnectCmd())
- // Before anything else: a connector-started worker's task token arrives on
- // an inherited descriptor, and the root command's persistent hooks — config
- // hardening, profile loading, the update check — run before any command's
- // own RunE and may start a process that would inherit it. This keeps the
- // descriptor from those children and takes a stale token out of the
- // environment; the token itself is read by the command, once cobra has
- // accepted the invocation.
- commands.PrepareConnectToken(os.Args[1:])
-
// Tier-2 stdin guard: reject a stray literal "-" when stdin is piped,
// everywhere a command doesn't explicitly accept it — except cobra's
// generated meta commands, which are deliberately exempt (see
diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go
index 2ad8986b9..5b4fc75cb 100644
--- a/internal/commands/mcp.go
+++ b/internal/commands/mcp.go
@@ -5,12 +5,9 @@ import (
"errors"
"fmt"
"log/slog"
- "math"
"os"
"os/signal"
"path/filepath"
- "slices"
- "strconv"
"strings"
"syscall"
"time"
@@ -22,6 +19,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/mcpserver"
"github.com/basecamp/basecamp-cli/internal/output"
+ "github.com/basecamp/basecamp-cli/internal/sysfd"
)
// mcpTransport is a seam so tests can drive the server over in-memory
@@ -29,67 +27,23 @@ import (
var mcpTransport = func() mcp.Transport { return &mcp.StdioTransport{} }
// connectTaskTokenEnv is where an earlier draft of the connector put a
-// worker's task token. It is not a way in: a token found there is removed and
-// the server refuses to start, so nothing is led to hand it over that way.
+// worker's task token. It is not a way in: the command refuses to serve when
+// a token is found there, so nothing is led to hand it over that way.
const connectTaskTokenEnv = "BASECAMP_CONNECT_TASK_TOKEN"
-// connectTokenEnvRefused records that a task token was found in the
-// environment at startup. It is taken out there and refused when the server
-// would serve the connect domain: the environment is not a way in.
-var connectTokenEnvRefused bool
-
-// PrepareConnectToken makes a connector-started worker's task token safe to
-// read later, and takes any stale token out of the environment. It runs before
-// the command tree is built, and it reads nothing.
-//
-// The hazard it closes is inheritance: the root command's persistent hooks
-// load configuration, tighten directories and may start a background update
-// check, and a child started then would inherit an open descriptor. Marking
-// the descriptor close-on-exec ends that, without consuming it — so the token
-// is still there to be read by the command itself, once cobra has decided the
-// invocation is one that serves. Reading it here instead would drain a
-// one-shot pipe for every invocation cobra goes on to refuse.
-//
-// The scan is deliberately loose, because what it does is harmless: a
-// descriptor that is not a token pipe is no worse for being close-on-exec in
-// a process that is about to serve MCP on stdio, and one that is not ours is
-// not touched, since the flag has to be there to be found.
-func PrepareConnectToken(args []string) {
- if _, set := os.LookupEnv(connectTaskTokenEnv); set {
- _ = os.Unsetenv(connectTaskTokenEnv)
- connectTokenEnvRefused = true
- }
- if fd, ok := connectTokenFDArg(args); ok {
- markCloseOnExec(fd)
- }
-}
-
-// connectTokenFDArg finds a --connect-token-fd value in the arguments of an
-// mcp command. It decides nothing about the invocation: the command's own flag
-// parsing does that, and this only says which descriptor to keep from a child.
-func connectTokenFDArg(args []string) (int, bool) {
- if !slices.Contains(args, "mcp") {
- return 0, false
+// connectTokenDescriptor reads the descriptor the task token arrives on.
+// Which descriptors are acceptable for a token is readTaskToken's business;
+// this answers only whether one was named at all, and whether the value is a
+// descriptor this process could act on.
+func connectTokenDescriptor(value string) (sysfd.Descriptor, error) {
+ if strings.TrimSpace(value) == "" {
+ return 0, output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
}
- for i, arg := range args {
- value, isFlag := strings.CutPrefix(arg, "--connect-token-fd")
- switch {
- case !isFlag:
- continue
- case strings.HasPrefix(value, "="):
- value = value[1:]
- case value != "":
- continue
- case i+1 < len(args):
- value = args[i+1]
- default:
- continue
- }
- if fd, err := strconv.ParseInt(value, 0, 64); err == nil && fd >= 3 && fd <= math.MaxInt32 {
- return int(fd), true
- }
+ descriptor, err := sysfd.Parse(value)
+ if err != nil {
+ return 0, output.ErrUsage(fmt.Sprintf("--connect-token-fd: %v", err))
}
- return 0, false
+ return descriptor, nil
}
// connectStateGiven is the one rule for whether a state directory was given,
@@ -109,7 +63,7 @@ func NewMCPCmd() *cobra.Command {
var readOnly bool
var domains []string
var connectState string
- var connectTokenFD int
+ var connectTokenFD string
cmd := &cobra.Command{
Use: "mcp",
@@ -141,13 +95,16 @@ func NewMCPCmd() *cobra.Command {
app := appctx.FromContext(cmd.Context())
// The task token is read, and its descriptor closed, before
- // anything else runs: authentication can start helper processes,
- // and a child started then would inherit an open descriptor.
+ // anything else in this command runs: authentication can start
+ // helper processes, and this leaves them nothing to inherit even
+ // if a descriptor arrived without the close-on-exec flag startup
+ // put on it.
var taskToken string
- // A token in the environment was taken out at startup, before the
- // hooks that could have passed it to a child. It is refused here:
- // the environment is not a way in for any server.
- if connectTokenEnvRefused {
+ // The environment is not a way in for any server: refused, and
+ // taken out of this process's environment as well, so nothing it
+ // might still start could read it there.
+ if _, set := os.LookupEnv(connectTaskTokenEnv); set {
+ _ = os.Unsetenv(connectTaskTokenEnv)
return output.ErrUsageHint("$"+connectTaskTokenEnv+" is not read",
"Hand the task token over on an inherited descriptor with --connect-token-fd, so it never sits in an environment.")
}
@@ -160,10 +117,15 @@ func NewMCPCmd() *cobra.Command {
// the token or the ledger is touched.
return output.ErrUsage("--connect-state cannot be combined with --read-only: every basecamp_connect action records what the worker did")
}
- // Read here, once cobra has accepted the invocation: the
- // descriptor has been close-on-exec since startup, so nothing
- // the hooks started could have inherited it.
- token, err := readTaskToken(connectTokenFD)
+ // Read here, once cobra has accepted the invocation, so a
+ // one-shot pipe is not drained for a run that never serves.
+ // The descriptor has been close-on-exec since the program's
+ // first line, so nothing started in between inherited it.
+ descriptor, err := connectTokenDescriptor(connectTokenFD)
+ if err != nil {
+ return err
+ }
+ token, err := readTaskToken(descriptor)
if err != nil {
return err
}
@@ -221,7 +183,7 @@ func NewMCPCmd() *cobra.Command {
cmd.Flags().BoolVar(&readOnly, "read-only", false, "Serve only read-only actions")
cmd.Flags().StringSliceVar(&domains, "domains", nil, "Narrow to specific domains (comma-separated; default all)")
cmd.Flags().StringVar(&connectState, "connect-state", "", "Serve the basecamp_connect domain from this connector state directory, for the task whose token arrives on --connect-token-fd")
- cmd.Flags().IntVar(&connectTokenFD, "connect-token-fd", -1, "Read the task token from this inherited file descriptor (3 or above), then close it")
+ cmd.Flags().StringVar(&connectTokenFD, "connect-token-fd", "", "Read the task token from this inherited file descriptor (3 or above), then close it")
return cmd
}
diff --git a/internal/commands/mcp_cloexec_other.go b/internal/commands/mcp_cloexec_other.go
deleted file mode 100644
index 24c0cb3d0..000000000
--- a/internal/commands/mcp_cloexec_other.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !unix
-
-package commands
-
-// markCloseOnExec has nothing to do where the connector does not run: the
-// command refuses --connect-state there.
-func markCloseOnExec(int) {}
diff --git a/internal/commands/mcp_cloexec_unix.go b/internal/commands/mcp_cloexec_unix.go
deleted file mode 100644
index 17c6497ae..000000000
--- a/internal/commands/mcp_cloexec_unix.go
+++ /dev/null
@@ -1,15 +0,0 @@
-//go:build unix
-
-package commands
-
-import "golang.org/x/sys/unix"
-
-// markCloseOnExec keeps fd out of the processes this one starts. Best effort:
-// a descriptor that is not open, or not ours, is nothing to protect, and the
-// command refuses it when it tries to read the token from it.
-func markCloseOnExec(fd int) {
- handle := uintptr(fd) //nolint:gosec // G115: connectTokenFDArg only reports a descriptor in [3, math.MaxInt32], so this cannot wrap
- if flags, err := unix.FcntlInt(handle, unix.F_GETFD, 0); err == nil {
- _, _ = unix.FcntlInt(handle, unix.F_SETFD, flags|unix.FD_CLOEXEC)
- }
-}
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_unix_test.go
index 401fbc0ca..9878df53c 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_unix_test.go
@@ -13,8 +13,6 @@ import (
"testing"
"time"
- "golang.org/x/sys/unix"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -102,6 +100,7 @@ func TestMCPCommandRefusesATokenInTheEnvironment(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "--connect-token-fd")
assert.Empty(t, os.Getenv("BASECAMP_CONNECT_TASK_TOKEN"))
+ assert.True(t, fdOpen(fd), "and the descriptor it never got to was left alone")
}
// A descriptor that is not a pipe or a socket is refused and left alone: a
@@ -140,6 +139,9 @@ func TestMCPCommandRefusesABadTokenDescriptor(t *testing.T) {
want string
}{
"no descriptor": {[]string{"--connect-state", dir}, "--connect-token-fd"},
+ "a blank descriptor": {[]string{"--connect-state", dir, "--connect-token-fd", " "}, "--connect-token-fd"},
+ "not a number": {[]string{"--connect-state", dir, "--connect-token-fd", "three"}, "not a file descriptor"},
+ "past what int can hold": {[]string{"--connect-state", dir, "--connect-token-fd", "2147483648"}, "out of range"},
"stdin is the MCP wire": {[]string{"--connect-state", dir, "--connect-token-fd", "0"}, "3 or above"},
"stdout": {[]string{"--connect-state", dir, "--connect-token-fd", "1"}, "3 or above"},
"not open": {[]string{"--connect-state", dir, "--connect-token-fd", "987"}, "it is not open"},
@@ -212,59 +214,3 @@ func TestABlankStateDirectoryIsNoStateDirectory(t *testing.T) {
nowDev, nowIno, open := fdIdentity(t, fd)
assert.True(t, open && nowDev == dev && nowIno == ino, "and the descriptor was not touched")
}
-
-// Startup keeps the token descriptor out of anything the process starts, and
-// reads nothing: the command reads it once cobra has accepted the invocation,
-// so a one-shot pipe is never drained for a run that never serves.
-func TestPrepareConnectTokenMarksTheDescriptorCloseOnExec(t *testing.T) {
- fd := tokenPipe(t, "a-task-token\n")
- before, err := fcntlGetFD(fd)
- require.NoError(t, err)
- require.Zero(t, before&unix.FD_CLOEXEC, "inherited descriptors arrive without it")
-
- PrepareConnectToken([]string{"mcp", "--connect-state", "/x", "--connect-token-fd", strconv.Itoa(fd)})
-
- after, err := fcntlGetFD(fd)
- require.NoError(t, err)
- assert.NotZero(t, after&unix.FD_CLOEXEC, "no child of this process inherits it")
- assert.True(t, fdOpen(fd), "and it is still there for the command to read")
-}
-
-func TestPrepareConnectTokenLooksOnlyWhereItShould(t *testing.T) {
- fd := tokenPipe(t, "token\n")
- for name, args := range map[string][]string{
- "another command": {"search", "--connect-token-fd", strconv.Itoa(fd)},
- "no flag": {"mcp", "--connect-state", "/x"},
- "standard input": {"mcp", "--connect-token-fd", "0"},
- "not a number": {"mcp", "--connect-token-fd", "three"},
- "nothing after": {"mcp", "--connect-token-fd"},
- } {
- t.Run(name, func(t *testing.T) {
- _, found := connectTokenFDArg(args)
- assert.False(t, found)
- })
- }
- for name, args := range map[string][]string{
- "a value of its own": {"mcp", "--connect-token-fd", strconv.Itoa(fd)},
- "joined with an =": {"mcp", "--connect-token-fd=" + strconv.Itoa(fd)},
- "after a root flag": {"--json", "mcp", "--connect-token-fd", strconv.Itoa(fd)},
- } {
- t.Run(name, func(t *testing.T) {
- got, found := connectTokenFDArg(args)
- require.True(t, found)
- assert.Equal(t, fd, got)
- })
- }
-}
-
-// A stale token in the environment is taken out at startup, before the hooks
-// that could pass it to a child, and the command then refuses to serve.
-func TestPrepareConnectTokenTakesAStaleEnvironmentTokenOut(t *testing.T) {
- t.Setenv("BASECAMP_CONNECT_TASK_TOKEN", "stale")
- t.Cleanup(func() { connectTokenEnvRefused = false })
-
- PrepareConnectToken([]string{"mcp", "--connect-state", "/x"})
-
- assert.Empty(t, os.Getenv("BASECAMP_CONNECT_TASK_TOKEN"))
- assert.True(t, connectTokenEnvRefused)
-}
diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go
index 8416e3ad3..b95695799 100644
--- a/internal/commands/mcp_test.go
+++ b/internal/commands/mcp_test.go
@@ -50,10 +50,6 @@ func setupMCPTestApp(t *testing.T, accountID, baseURL string) *appctx.App {
func executeMCPCommand(t *testing.T, app *appctx.App, args ...string) error {
t.Helper()
- // As cli.Execute does, before the command tree runs at all.
- connectTokenEnvRefused = false
- PrepareConnectToken(append([]string{"mcp"}, args...))
- t.Cleanup(func() { connectTokenEnvRefused = false })
cmd := NewMCPCmd()
cmd.SetArgs(args)
cmd.SetContext(appctx.WithApp(context.Background(), app))
diff --git a/internal/commands/mcp_token_other.go b/internal/commands/mcp_token_other.go
index 83063bbfc..2b1953c42 100644
--- a/internal/commands/mcp_token_other.go
+++ b/internal/commands/mcp_token_other.go
@@ -2,10 +2,13 @@
package commands
-import "github.com/basecamp/basecamp-cli/internal/output"
+import (
+ "github.com/basecamp/basecamp-cli/internal/output"
+ "github.com/basecamp/basecamp-cli/internal/sysfd"
+)
// readTaskToken is refused where the connector cannot run: its ledger's
// privacy cannot be established off Unix, so no worker is started there.
-func readTaskToken(int) (string, error) {
+func readTaskToken(sysfd.Descriptor) (string, error) {
return "", output.ErrUsage("--connect-state is only available on Unix, where the connector runs")
}
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_unix.go
index de650dc60..4e1715231 100644
--- a/internal/commands/mcp_token_unix.go
+++ b/internal/commands/mcp_token_unix.go
@@ -14,15 +14,20 @@ import (
"golang.org/x/sys/unix"
"github.com/basecamp/basecamp-cli/internal/output"
+ "github.com/basecamp/basecamp-cli/internal/sysfd"
)
+// firstTokenFD is the lowest descriptor a task token may arrive on: below it
+// are stdin and stdout, which are the MCP wire, and stderr, which is the log.
+const firstTokenFD = 3
+
// readTaskToken reads the task token from an inherited descriptor and closes
// it. The connector hands the token over as the read end of a pipe, so it never
// exists at a path, in argv or in the environment; once read, the descriptor
// is gone too, and nothing this process starts can inherit it.
//
-// Descriptors 0 to 2 are refused: stdin and stdout are the MCP wire and stderr
-// is the log. Only a pipe or a socket is taken, and anything else is left
+// Descriptors below firstTokenFD are refused. Only a pipe or a socket is
+// taken, and anything else is left
// exactly as it was — not read, not closed: a regular file would be the token
// at a path, and a wrong number could name a descriptor this process already
// uses.
@@ -30,12 +35,10 @@ import (
// The read ends at the first newline or at end of file, and is bounded in
// size and in time, so a write end left open somewhere cannot hang startup. A
// sender writes "token\n", or closes its end after the token.
-func readTaskToken(fd int) (string, error) {
- switch {
- case fd < 0:
- return "", output.ErrUsage("--connect-state needs the task token on an inherited descriptor: pass --connect-token-fd")
- case fd < 3:
- return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be 3 or above", fd))
+func readTaskToken(descriptor sysfd.Descriptor) (string, error) {
+ fd := descriptor.Int()
+ if fd < firstTokenFD {
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be %d or above", fd, firstTokenFD))
}
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
@@ -55,7 +58,7 @@ func readTaskToken(fd int) (string, error) {
_ = unix.Close(fd)
return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
}
- file := os.NewFile(uintptr(fd), "connect-token")
+ file := os.NewFile(descriptor.Uintptr(), "connect-token")
defer file.Close()
if err := file.SetReadDeadline(time.Now().Add(taskTokenReadTimeout)); err != nil {
return "", output.ErrUsage(fmt.Sprintf("could not read the task token from descriptor %d: %v", fd, err))
diff --git a/internal/sysfd/sysfd.go b/internal/sysfd/sysfd.go
new file mode 100644
index 000000000..52f7a76a4
--- /dev/null
+++ b/internal/sysfd/sysfd.go
@@ -0,0 +1,60 @@
+// Package sysfd carries a file descriptor between the three places that name
+// one: a value a process is given on its command line, a *os.File or raw
+// connection Go hands over as a uintptr, and the syscall wrappers that take a
+// plain number. One type, so a descriptor is checked where it enters and
+// passed on afterwards without every caller repeating the bounds.
+package sysfd
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+)
+
+// Descriptor is a file descriptor this process may act on: non-negative, and
+// within int's range on every platform the CLI builds for. Parse and Of are
+// the only ways to make one, so anything holding a Descriptor holds a number
+// that converts safely.
+//
+// It says nothing about which descriptors are appropriate for a given job.
+// Whether standard input may be read as a token, for instance, belongs where
+// the flag is read, not here.
+type Descriptor int
+
+// maxDescriptor is the portable ceiling: int is 32 bits on a 32-bit build, so
+// a number that fits in int64 is not necessarily one this process can hold.
+const maxDescriptor = math.MaxInt32
+
+// Parse reads a descriptor a process was told about, e.g. the value of
+// --connect-token-fd.
+func Parse(value string) (Descriptor, error) {
+ number, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("%q is not a file descriptor", value)
+ }
+ if number < 0 || number > maxDescriptor {
+ return 0, fmt.Errorf("file descriptor %d is out of range (0 to %d)", number, maxDescriptor)
+ }
+ return Descriptor(number), nil
+}
+
+// Of takes a descriptor Go reports as a uintptr, as os.File.Fd and the
+// Control callback of a syscall.RawConn do.
+func Of(fd uintptr) (Descriptor, error) {
+ if fd > maxDescriptor {
+ return 0, fmt.Errorf("file descriptor %d is out of range (0 to %d)", fd, maxDescriptor)
+ }
+ return Descriptor(fd), nil
+}
+
+// Int is what a syscall wrapper takes.
+func (d Descriptor) Int() int { return int(d) }
+
+// Uintptr is what os.NewFile and the fcntl wrappers take.
+func (d Descriptor) Uintptr() uintptr {
+ return uintptr(d) //nolint:gosec // G115: Parse and Of are the only ways to make a Descriptor, and both refuse a negative number
+}
+
+// String is what a process is told on a command line.
+func (d Descriptor) String() string { return strconv.Itoa(int(d)) }
diff --git a/internal/sysfd/sysfd_test.go b/internal/sysfd/sysfd_test.go
new file mode 100644
index 000000000..d3d9322b1
--- /dev/null
+++ b/internal/sysfd/sysfd_test.go
@@ -0,0 +1,88 @@
+package sysfd_test
+
+import (
+ "math"
+ "os"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/basecamp/basecamp-cli/internal/sysfd"
+)
+
+// What a process can be told on a command line, and what it cannot.
+func TestParse(t *testing.T) {
+ for value, want := range map[string]int{
+ "0": 0,
+ "1": 1,
+ "3": 3,
+ " 3 ": 3,
+ "+3": 3,
+ strconv.Itoa(math.MaxInt32): math.MaxInt32,
+ } {
+ t.Run("takes "+value, func(t *testing.T) {
+ fd, err := sysfd.Parse(value)
+ require.NoError(t, err)
+ assert.Equal(t, want, fd.Int(), "and reads the number it was given")
+ })
+ }
+ for name, value := range map[string]string{
+ "nothing": "",
+ "whitespace": " ",
+ "a word": "three",
+ "a number and more": "3x",
+ "a negative": "-1",
+ "hexadecimal": "0x3",
+ "a float": "3.0",
+ "past int32": strconv.FormatInt(math.MaxInt32+1, 10),
+ "far past int64": "99999999999999999999",
+ } {
+ t.Run("refuses "+name, func(t *testing.T) {
+ _, err := sysfd.Parse(value)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "descriptor", "and says what it refused")
+ })
+ }
+}
+
+// The value is in the refusal: a caller passing on the error names the
+// descriptor that was wrong, not just that one was.
+func TestParseNamesTheValue(t *testing.T) {
+ _, err := sysfd.Parse("three")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"three"`)
+
+ _, err = sysfd.Parse("-1")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "-1")
+}
+
+// A descriptor Go hands back as a uintptr comes through unchanged.
+func TestOf(t *testing.T) {
+ file, err := os.Open(os.DevNull)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = file.Close() })
+
+ fd, err := sysfd.Of(file.Fd())
+ require.NoError(t, err)
+ assert.Equal(t, int(file.Fd()), fd.Int())
+ assert.Equal(t, file.Fd(), fd.Uintptr())
+}
+
+func TestOfRefusesWhatIntCannotHold(t *testing.T) {
+ _, err := sysfd.Of(uintptr(math.MaxInt32) + 1)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "out of range")
+}
+
+// The three shapes a caller asks for are the same number.
+func TestADescriptorIsOneNumber(t *testing.T) {
+ fd, err := sysfd.Parse("7")
+ require.NoError(t, err)
+
+ assert.Equal(t, 7, fd.Int())
+ assert.Equal(t, uintptr(7), fd.Uintptr())
+ assert.Equal(t, "7", fd.String())
+}
From a21cd408c9ae0cafad709d30234eaaec44e0e963 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Fri, 18 Sep 2026 08:41:15 +0200
Subject: [PATCH 29/33] Cover the second open's file-mode check, and a mention
tag that closes itself
Two gaps in the tests, not in the code. OpenExistingLedger already refuses a
ledger another user can read, with nothing asserting it. And the strip-mentions
property helper treated a self-closing bc-attachment as unterminated, so a span
that ran past one and swallowed the text after it would have been accepted as a
single element.
---
internal/connector/ledger_owner_unix_test.go | 20 ++++++++++++++++++++
internal/connector/strip_mentions_test.go | 12 ++++++++++--
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/internal/connector/ledger_owner_unix_test.go b/internal/connector/ledger_owner_unix_test.go
index 83e9afe92..0eaa66167 100644
--- a/internal/connector/ledger_owner_unix_test.go
+++ b/internal/connector/ledger_owner_unix_test.go
@@ -66,3 +66,23 @@ func TestASecondOpenVetsTheDirectoryChain(t *testing.T) {
assert.Contains(t, err.Error(), "secure the ledger")
assert.Equal(t, checks, securePathRuns.Load(), "and the file was not opened to find that out")
}
+
+// And a second open vets the ledger file's own mode: a file another user can
+// read is not one to read from, whatever it was when the first open checked.
+func TestASecondOpenVetsTheFileMode(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ require.NoError(t, os.Mkdir(dir, 0o700))
+ path := filepath.Join(dir, "connector.db")
+ first, err := OpenLedger(path)
+ require.NoError(t, err)
+ defer first.Close()
+ checks := securePathRuns.Load()
+
+ require.NoError(t, os.Chmod(path, 0o644))
+
+ _, err = OpenExistingLedger(context.Background(), path)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "can be read by other users (mode 0644)")
+ assert.Equal(t, checks, securePathRuns.Load(), "and the file was not opened to find that out")
+}
diff --git a/internal/connector/strip_mentions_test.go b/internal/connector/strip_mentions_test.go
index 294bac49a..60d543581 100644
--- a/internal/connector/strip_mentions_test.go
+++ b/internal/connector/strip_mentions_test.go
@@ -169,8 +169,10 @@ func restoreSpan(input string, removed [][2]int, keep [2]int) string {
}
// isOneMentionElement reports a removed span that is one bc-attachment
-// element: it opens with that tag, and every end tag inside it either closes
-// something the span itself opened or is the element's own closing tag. A span that
+// element: it opens with that tag and ends where that element ends — at the
+// start tag itself when the tag closes itself, and otherwise where every end
+// tag inside it either closes something the span opened or is the element's
+// own closing tag. A span that
// ran past its element and swallowed a "" from the text around it fails
// here.
func isOneMentionElement(element string) bool {
@@ -178,6 +180,12 @@ func isOneMentionElement(element string) bool {
if !ok || first.isEnd || !strings.EqualFold(first.name, "bc-attachment") || first.start != 0 {
return false
}
+ if strings.HasSuffix(element[first.start:first.end], "/>") {
+ // A tag that closes itself is the whole element. A span that ran on
+ // from one to some later closing tag swallowed the text between them,
+ // so it is not one element however that text ends.
+ return first.end == len(element)
+ }
var open []string
for at := first.end; at < len(element); {
t, ok := nextMarkup(element, at)
From b7cff610930524b719d49f8cddfea097a44fdcd4 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Fri, 18 Sep 2026 08:41:19 +0200
Subject: [PATCH 30/33] Write the conversation a task holds on the task's own
rows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Retention clears a terminal record's conversation_key, and nothing retires a
task when its work completes — a task stays live until it is superseded. So a
completed record whose task nobody superseded lost the key that kept its
conversation occupied, the self-join in createTask then found no sibling, and a
second task started beside the live one.
The conversation is now written on task_events when the row is written, where
retention does not reach, and a trigger refuses a second live task on it. That
leaves retention free to do its job instead of being taught to skip rows.
The guard also counts an older admitted sibling. Admission queues a record
precisely because an earlier one is next, and a queued record was going around
it: not two workers at once, because both writes take the write lock, but a
conversation split across two tasks and run out of order.
An acknowledgement now settles with its delivery, id and all. An id arriving
after the acknowledgement is a second report, not the same call retried, and a
trigger says so where one statement could have written both.
---
internal/connector/dispatch_lifecycle_test.go | 31 +++-
internal/connector/ledger.go | 36 ++++
internal/connector/ledger_dispatch.go | 159 ++++++++++++------
internal/connector/ledger_dispatch_test.go | 107 ++++++++++++
4 files changed, 281 insertions(+), 52 deletions(-)
diff --git a/internal/connector/dispatch_lifecycle_test.go b/internal/connector/dispatch_lifecycle_test.go
index a2810ebe0..99b6810f3 100644
--- a/internal/connector/dispatch_lifecycle_test.go
+++ b/internal/connector/dispatch_lifecycle_test.go
@@ -500,8 +500,11 @@ func testDeliveryNeedsAPull(t *testing.T) {
}
// A pull and a withdrawal are opposites: one says a worker has the
-// instruction, the other that none ever did. One statement cannot write both,
-// and a BEFORE trigger that read only the row as it was would let it.
+// instruction, the other that none ever did. No statement writes both, nor a
+// pull together with retirement or a move — whichever of the delivery rules
+// is the one that catches it. Which rule that is depends on the row's state;
+// TestOneWriteCannotWithdrawAndComplete pins the case where the withdrawal
+// rule's reading of the row being written is the only thing in the way.
func TestOneWriteCannotBothPullAndWithdraw(t *testing.T) {
for name, statement := range map[string]string{
"pull and withdraw": `UPDATE task_events SET pulled_at = 'now', withdrawn_at = 'now' WHERE event_id = 1`,
@@ -522,3 +525,27 @@ func TestOneWriteCannotBothPullAndWithdraw(t *testing.T) {
})
}
}
+
+// A withdrawal says no worker process ever existed; a completed delivery says
+// one reported an outcome. One statement does not write both — and here the
+// only thing that says so is the withdrawal rule reading the row as it is
+// being written: the task is superseded with no live row, nothing was pulled,
+// and the record was settled by the dispatcher, so every other delivery rule
+// lets this statement through.
+func TestOneWriteCannotWithdrawAndComplete(t *testing.T) {
+ ctx := context.Background()
+ f := newDispatchFixture(t)
+ _, err := f.ledger.db.ExecContext(ctx, `UPDATE task_events SET delivery = 'exposed', exposed_at = 'launch' WHERE event_id = 1`)
+ require.NoError(t, err)
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ require.NoError(t, f.ledger.SetState(ctx, 1, StateCompleted, ""))
+
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET withdrawn_at = 'now', delivery = 'completed' WHERE task_id = ? AND event_id = 1`, f.grant.ID)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "only a launch exposure no worker pulled")
+ var withdrawn *string
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT withdrawn_at FROM task_events WHERE task_id = ? AND event_id = 1`, f.grant.ID).Scan(&withdrawn))
+ assert.Nil(t, withdrawn)
+ assert.Equal(t, "exposed", f.rowContext(ctx, t, 1).Delivery)
+}
diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go
index 1e87f804e..2d2821f61 100644
--- a/internal/connector/ledger.go
+++ b/internal/connector/ledger.go
@@ -604,6 +604,11 @@ CREATE TABLE tasks (
CREATE TABLE task_events (
task_id INTEGER NOT NULL REFERENCES tasks (id),
event_id INTEGER NOT NULL REFERENCES events (id),
+ -- The conversation the event is on, copied from the record when the row is
+ -- written: retention clears a terminal record's conversation_key, and a
+ -- task outlives its records' payloads, so the conversation a live task
+ -- holds has to be written where it stays readable.
+ conversation_key TEXT NOT NULL DEFAULT '',
delivery TEXT NOT NULL DEFAULT 'admitted'
CHECK (delivery IN ('admitted', 'exposed', 'delivered', 'completed')),
guard TEXT NOT NULL DEFAULT ''
@@ -623,6 +628,7 @@ CREATE TABLE task_events (
CREATE UNIQUE INDEX task_events_one_live_task ON task_events (event_id) WHERE retired_at IS NULL;
CREATE INDEX task_events_event ON task_events (event_id, delivery);
+CREATE INDEX task_events_conversation ON task_events (conversation_key) WHERE retired_at IS NULL;
CREATE TRIGGER tasks_supersession_is_final
BEFORE UPDATE OF superseded_at ON tasks
@@ -715,6 +721,15 @@ BEGIN
SELECT RAISE(ABORT, 'a record is dispatched exactly while a live task carries it');
END;
+-- The acknowledgement settles with the delivery: the id a worker points at is
+-- written when it acknowledges, or never.
+CREATE TRIGGER task_events_acknowledgement_settles_once
+BEFORE UPDATE OF ack_id ON task_events
+WHEN NEW.ack_id IS NOT OLD.ack_id AND (OLD.ack_id IS NOT NULL OR OLD.delivery <> 'exposed')
+BEGIN
+ SELECT RAISE(ABORT, 'an acknowledgement id is written with the acknowledgement, once');
+END;
+
CREATE TRIGGER task_events_guard_settles_once
BEFORE UPDATE OF guard ON task_events
WHEN NEW.guard <> OLD.guard AND NOT (OLD.guard = 'armed' AND NEW.guard IN ('canceled', 'fired'))
@@ -735,6 +750,27 @@ BEGIN
SELECT RAISE(ABORT, 'only work waiting for a worker joins a task, and only a live one');
END;
+-- One live task per conversation (invariant 2), as a rule about the rows
+-- rather than about the records: two workers on one conversation would answer
+-- each other's work, and a conversation whose records have since been
+-- retained must still count.
+CREATE TRIGGER task_events_one_live_task_per_conversation
+BEFORE INSERT ON task_events
+WHEN NEW.conversation_key <> '' AND NEW.retired_at IS NULL AND EXISTS (
+ SELECT 1 FROM task_events live
+ WHERE live.conversation_key = NEW.conversation_key
+ AND live.retired_at IS NULL AND live.task_id <> NEW.task_id)
+BEGIN
+ SELECT RAISE(ABORT, 'one live task per conversation');
+END;
+
+CREATE TRIGGER task_events_conversation_does_not_move
+BEFORE UPDATE OF conversation_key ON task_events
+WHEN NEW.conversation_key <> OLD.conversation_key
+BEGIN
+ SELECT RAISE(ABORT, 'a task event stays on the conversation it was written for');
+END;
+
CREATE TRIGGER task_events_do_not_move
BEFORE UPDATE OF task_id, event_id ON task_events
WHEN NEW.task_id <> OLD.task_id OR NEW.event_id <> OLD.event_id
diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go
index a75a55df2..59c8dbfbb 100644
--- a/internal/connector/ledger_dispatch.go
+++ b/internal/connector/ledger_dispatch.go
@@ -88,7 +88,11 @@ import (
// # Delivery (task_events.delivery), per event on a task
//
// admitted → exposed worker (get_dispatch), dispatcher at launch
-// exposed → delivered worker (ack_dispatch)
+// exposed → delivered worker (ack_dispatch); the id the worker points at,
+// when it has one, is written with this move or never
+// (task_events_acknowledgement_settles_once), so an id
+// arriving after the acknowledgement is a second
+// report and refused
// exposed → completed worker (complete_dispatch)
// exposed → completed dispatcher settlement (worker gone before ack)
// delivered → completed worker (complete_dispatch), dispatcher settlement
@@ -115,9 +119,15 @@ import (
// # Invariants
//
// 1. One live task per event: task_events_one_live_task.
-// 2. One task per conversation: an event joins a task only if every
-// dispatched record on its conversation joins the same task, and no live
-// task carries any of that conversation's events (createTask).
+// 2. One task per conversation, and in the conversation's order: an event
+// joins a task only if no live task holds its conversation, no record on
+// that conversation is dispatched outside this task, and no admitted
+// record older than it is waiting there — admission queues a record
+// precisely because an earlier one is next, and a queued record does not
+// go around it (conversationsAreFree, and
+// task_events_one_live_task_per_conversation). The conversation a task
+// holds is written on the task's own rows, because retention clears a
+// terminal record's conversation_key while its task is still live.
// 3. The token is valid only while its task is live, checked inside every
// worker call's own transaction.
// 4. Nothing leaves dispatched while a worker may still act: a record with a
@@ -262,12 +272,13 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
// Read first, write after: a refusal leaves the caller's transaction as
// it found it, whatever the caller then does with it.
guards := make([]string, 0, len(eventIDs))
+ conversations := make([]string, 0, len(eventIDs))
for _, id := range eventIDs {
var (
acknowledge, hasInstruction int
- state string
+ state, conversation string
)
- switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL AND length(snapshot) > 0, state FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction, &state); {
+ switch err := tx.QueryRowContext(ctx, `SELECT acknowledge, content_dropped = 0 AND snapshot IS NOT NULL AND length(snapshot) > 0, state, conversation_key FROM events WHERE id = ?`, id).Scan(&acknowledge, &hasInstruction, &state, &conversation); {
case errors.Is(err, sql.ErrNoRows):
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrNoSuchRecord)
case err != nil:
@@ -296,40 +307,14 @@ func (l *Ledger) createTask(ctx context.Context, tx *sql.Tx, eventIDs []int64) (
guard = "armed"
}
guards = append(guards, guard)
+ conversations = append(conversations, conversation)
}
- // One task per conversation: every dispatched record on the events'
- // conversations must be among the events this task takes. Checked before
- // anything is written, so a refusal leaves the caller's transaction
- // untouched, and before any record moves, so the records this call
- // dispatches never count.
- placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(eventIDs)), ", ")
- args := make([]any, 0, len(eventIDs)*2)
- for _, id := range eventIDs {
- args = append(args, id)
- }
- for _, id := range eventIDs {
- args = append(args, id)
- }
- var busy int64
- //nolint:gosec // G202: placeholders, not values
- //
- // Busy is a record still dispatched on the conversation, or one a live
- // task still carries: a task stays live until it is superseded, whatever
- // became of its records, and two live tasks on one conversation would be
- // two workers on it.
- switch err := tx.QueryRowContext(ctx, `
-SELECT other.id FROM events other
-JOIN events mine ON mine.conversation_key = other.conversation_key
-WHERE mine.id IN (`+placeholders+`) AND mine.conversation_key <> ''
- AND other.id NOT IN (`+placeholders+`)
- AND (other.state = 'dispatched'
- OR EXISTS (SELECT 1 FROM task_events WHERE event_id = other.id AND retired_at IS NULL))
-LIMIT 1`, args...).Scan(&busy); {
- case err == nil:
- return TaskGrant{}, fmt.Errorf("connector: event %d is dispatched on the same conversation: %w", busy, ErrConversationBusy)
- case !errors.Is(err, sql.ErrNoRows):
- return TaskGrant{}, fmt.Errorf("connector: read conversations: %w", err)
+ // One task per conversation (invariant 2), asked as two questions, both
+ // before anything is written: a refusal leaves the caller's transaction
+ // untouched, and the records this call dispatches never count themselves.
+ if err := l.conversationsAreFree(ctx, tx, eventIDs, conversations); err != nil {
+ return TaskGrant{}, err
}
res, err := tx.ExecContext(ctx, `INSERT INTO tasks (token_sha256, created_at) VALUES (?, ?)`, tokenHash(token), l.timestamp())
@@ -341,7 +326,7 @@ LIMIT 1`, args...).Scan(&busy); {
return TaskGrant{}, fmt.Errorf("connector: create task: %w", err)
}
for i, id := range eventIDs {
- if _, err := tx.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id, guard) VALUES (?, ?, ?)`, taskID, id, guards[i]); err != nil {
+ if _, err := tx.ExecContext(ctx, `INSERT INTO task_events (task_id, event_id, guard, conversation_key) VALUES (?, ?, ?, ?)`, taskID, id, guards[i], conversations[i]); err != nil {
if isConstraint(err) {
return TaskGrant{}, fmt.Errorf("connector: task event %d: %w", id, ErrEventOnLiveTask)
}
@@ -364,11 +349,77 @@ LIMIT 1`, args...).Scan(&busy); {
return TaskGrant{ID: taskID, Token: token}, nil
}
+// conversationsAreFree asks whether the conversations these events are on are
+// free for a task of their own.
+//
+// Two questions, because the answer lives in two places. A record waiting for
+// a worker or still dispatched is in events: admitted means admission decided
+// it goes next and a queued sibling may not jump ahead of it, and dispatched
+// means work a worker may still be holding, whether or not a live task
+// carries it (invariant 7). A conversation a live task already holds is in
+// task_events, which keeps the conversation of every row it was written for —
+// the record's own conversation_key is cleared once retention reaches it, and
+// a task whose records are all terminal is still a worker's task until it is
+// superseded.
+func (l *Ledger) conversationsAreFree(ctx context.Context, tx *sql.Tx, eventIDs []int64, conversations []string) error {
+ placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(eventIDs)), ", ")
+ for i, conversation := range conversations {
+ if conversation == "" {
+ continue
+ }
+ args := make([]any, 0, len(eventIDs)+1)
+ args = append(args, conversation)
+ for _, id := range eventIDs {
+ args = append(args, id)
+ }
+
+ var (
+ other int64
+ otherState string
+ )
+ //nolint:gosec // G202: placeholders, not values
+ //
+ // An older admitted sibling is next on its conversation, and a queued
+ // one does not go around it: admission queues a record precisely
+ // because an earlier one holds the conversation. A dispatched sibling
+ // counts whatever its id, because a worker may still be holding it.
+ switch err := tx.QueryRowContext(ctx, `
+SELECT id, state FROM events
+WHERE conversation_key = ? AND id NOT IN (`+placeholders+`)
+ AND (state = 'dispatched' OR (state = 'admitted' AND id < ?))
+ORDER BY id LIMIT 1`, append(args, eventIDs[i])...).Scan(&other, &otherState); {
+ case err == nil && RecordState(otherState) == StateDispatched:
+ return fmt.Errorf("connector: event %d is dispatched on the same conversation: %w", other, ErrConversationBusy)
+ case err == nil:
+ return fmt.Errorf("connector: event %d is admitted ahead of event %d on the same conversation: %w", other, eventIDs[i], ErrConversationBusy)
+ case !errors.Is(err, sql.ErrNoRows):
+ return fmt.Errorf("connector: read conversations: %w", err)
+ }
+
+ var task int64
+ //nolint:gosec // G202: placeholders, not values
+ switch err := tx.QueryRowContext(ctx, `
+SELECT task_id FROM task_events
+WHERE conversation_key = ? AND retired_at IS NULL AND event_id NOT IN (`+placeholders+`)
+ORDER BY task_id LIMIT 1`, args...).Scan(&task); {
+ case err == nil:
+ return fmt.Errorf("connector: task %d is live on the conversation of event %d: %w", task, eventIDs[i], ErrConversationBusy)
+ case !errors.Is(err, sql.ErrNoRows):
+ return fmt.Errorf("connector: read live tasks: %w", err)
+ }
+ }
+ return nil
+}
+
// SupersedeTask retires a task: its token is refused from then on, and its
-// events are free to join a new task. An event the task never exposed returns
-// to admitted, to be dispatched again once its conversation is free; an event
-// a worker was handed stays dispatched, because that worker may have acted on
-// it, and waits for its settlement or a person's redispatch.
+// events are free to join a new task. An event this task never exposed
+// returns to admitted, to be dispatched again once its conversation is free —
+// unless something else still holds it dispatched, which invariant 4 decides
+// and this call does not override: an earlier task's exposure that was never
+// withdrawn keeps the record dispatched, waiting for its outcome, and a
+// record a person or a later verdict has moved stays where it was put. An
+// event a worker was handed stays dispatched for the same reason: that worker
+// may have acted on it, and it waits for its settlement or a redispatch.
func (l *Ledger) SupersedeTask(ctx context.Context, taskID int64) error {
return retryBusy(func() error {
tx, err := l.db.BeginTx(ctx, nil)
@@ -421,8 +472,11 @@ func (l *Ledger) supersedeTask(ctx context.Context, tx *sql.Tx, taskID int64) er
return fmt.Errorf("connector: supersede task %d: %w", taskID, err)
}
for _, id := range unexposed {
- // Only a record still dispatched moves: one a person or a later
- // verdict already moved stays where it was put.
+ // Only a record still dispatched moves, and only if nothing else
+ // holds it: move's own refusals — a worker holding it on another
+ // task, a record a person or a later verdict moved — are the answer,
+ // not an error, so the result is read for what it is and not acted
+ // on.
if _, err := l.move(ctx, tx, transition{id: id, state: StateAdmitted, from: []RecordState{StateDispatched}}); err != nil {
return err
}
@@ -747,12 +801,17 @@ func (d *TaskDispatch) Ack(ctx context.Context, eventID int64, ackID *int64) (Re
if ackID != nil && te.ackID.Valid && te.ackID.Int64 != *ackID {
return false, fmt.Errorf("connector: event %d acknowledged as %d: %w", eventID, te.ackID.Int64, ErrReportConflict)
}
- if ackID != nil && !te.ackID.Valid && te.delivery == DeliveryCompleted {
- // The outcome stands, and so does what was reported with it:
- // an acknowledgement arriving after it is not written.
- return false, fmt.Errorf("connector: event %d is completed: %w", eventID, ErrReportConflict)
+ if ackID != nil && !te.ackID.Valid && te.delivery != DeliveryExposed {
+ // The acknowledgement settles with the delivery, id and all:
+ // this event was acknowledged with nothing to point at, and
+ // an id arriving afterwards is a second, different report,
+ // not the same call retried.
+ return false, fmt.Errorf("connector: event %d was acknowledged without an acknowledgement id: %w", eventID, ErrReportConflict)
}
- if te.delivery != DeliveryExposed && (ackID == nil || te.ackID.Valid) {
+ if te.delivery != DeliveryExposed {
+ // Acknowledged already, and by whatever this call says: an
+ // id equal to the one recorded, or none to record. The
+ // receipt is the answer.
return false, nil
}
_, err := tx.ExecContext(ctx, `
diff --git a/internal/connector/ledger_dispatch_test.go b/internal/connector/ledger_dispatch_test.go
index 2c847cd6d..7042e2141 100644
--- a/internal/connector/ledger_dispatch_test.go
+++ b/internal/connector/ledger_dispatch_test.go
@@ -1261,3 +1261,110 @@ func TestAWithdrawnExposureDoesNotFollowTheRetry(t *testing.T) {
require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT delivery FROM task_events WHERE task_id = ? AND event_id = 1`, f.grant.ID).Scan(&delivery))
assert.Equal(t, "exposed", delivery)
}
+
+// A queued record does not go around the admitted one that holds its
+// conversation: admission queued it precisely because that one is next.
+func TestAQueuedRecordDoesNotJumpItsConversation(t *testing.T) {
+ ctx := context.Background()
+ ledger, err := OpenLedger(filepath.Join(t.TempDir(), "state", "connector.db"))
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ledger.Close() })
+ for _, id := range []int64{1, 2} {
+ seenRecord(t, ledger, id)
+ _, err := ledger.Admission().Commit(ctx, admittedVerdict(id, 0, "recording:10304028989"))
+ require.NoError(t, err)
+ }
+ require.Equal(t, StateQueued, getRecord(t, ledger, 2).State)
+
+ _, err = ledger.CreateTask(ctx, []int64{2})
+
+ require.ErrorIs(t, err, ErrConversationBusy)
+ assert.Contains(t, err.Error(), "event 1 is admitted ahead of event 2")
+ _, err = ledger.CreateTask(ctx, []int64{1})
+ assert.NoError(t, err, "and the record that is next dispatches")
+}
+
+// A task is its worker's until it is superseded, even after retention has
+// cleared what its records said: the conversation a task holds is written on
+// the task's own rows, where retention does not reach.
+func TestRetentionDoesNotFreeALiveTasksConversation(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ for _, id := range []int64{1, 2} {
+ _, _, err := f.d.Get(ctx, id)
+ require.NoError(t, err)
+ _, err = f.d.Complete(ctx, id, Completion{Outcome: OutcomeSucceeded})
+ require.NoError(t, err)
+ }
+ dropped, err := f.ledger.DropContent(ctx, time.Now().Add(time.Hour), time.Now().Add(time.Hour))
+ require.NoError(t, err)
+ require.Equal(t, 2, dropped, "the records' payloads are gone, conversation and all")
+
+ seenRecord(t, f.ledger, 3)
+ _, err = f.ledger.Admission().Commit(ctx, admittedVerdict(3, 0, "recording:10304028989"))
+ require.NoError(t, err)
+ _, err = f.ledger.CreateTask(ctx, []int64{3})
+
+ require.ErrorIs(t, err, ErrConversationBusy)
+ assert.Contains(t, err.Error(), "is live on the conversation of event 3")
+ require.NoError(t, f.ledger.SupersedeTask(ctx, f.grant.ID))
+ _, err = f.ledger.CreateTask(ctx, []int64{3})
+ assert.NoError(t, err, "and the conversation is free once the task is not")
+}
+
+// An acknowledgement with an id, after one without, is a second report, not
+// the same call retried: the receipt stands and nothing is written.
+func TestAnAcknowledgementIdAfterTheAcknowledgementIsRefused(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, err = f.d.Ack(ctx, 1, nil)
+ require.NoError(t, err)
+
+ late := int64(4242)
+ _, err = f.d.Ack(ctx, 1, &late)
+
+ require.ErrorIs(t, err, ErrReportConflict)
+ assert.Contains(t, err.Error(), "acknowledged without an acknowledgement id")
+ var ack *int64
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT ack_id FROM task_events WHERE event_id = 1`).Scan(&ack))
+ assert.Nil(t, ack, "nothing was written")
+}
+
+// A retry that has nothing to point at answers the receipt it already has: no
+// id is not a different id.
+func TestAnAcknowledgementWithoutAnIdAfterOneWithItIsTheSameReceipt(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ posted := int64(4242)
+ first, err := f.d.Ack(ctx, 1, &posted)
+ require.NoError(t, err)
+
+ again, err := f.d.Ack(ctx, 1, nil)
+
+ require.NoError(t, err)
+ assert.Equal(t, first, again)
+ var ack *int64
+ require.NoError(t, f.ledger.db.QueryRowContext(ctx, `SELECT ack_id FROM task_events WHERE event_id = 1`).Scan(&ack))
+ require.NotNil(t, ack)
+ assert.Equal(t, posted, *ack, "and the id it did report is still there")
+}
+
+// The same rule holds for anything else writing to the file: an id is written
+// with the acknowledgement or not at all.
+func TestTheSchemaKeepsAnAcknowledgementIdWithItsAcknowledgement(t *testing.T) {
+ f := newDispatchFixture(t)
+ ctx := context.Background()
+ _, _, err := f.d.Get(ctx, 1)
+ require.NoError(t, err)
+ _, err = f.d.Ack(ctx, 1, nil)
+ require.NoError(t, err)
+
+ _, err = f.ledger.db.ExecContext(ctx, `UPDATE task_events SET ack_id = 4242 WHERE event_id = 1`)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "written with the acknowledgement, once")
+}
From 07ee317f6df7f4eb88a5d1a55f7c94b3be4caa57 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Fri, 18 Sep 2026 08:36:14 +0200
Subject: [PATCH 31/33] Refuse to start when an inherited descriptor cannot be
sealed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sealing every inherited descriptor close-on-exec is what lets a
connector-started worker be handed its task token on a pipe: nothing the
pre-command hooks start can inherit it. The walk that stands in for
CLOSE_RANGE_CLOEXEC on kernels before 5.11 returned quietly whenever it
could not open or read /proc/self/fd, and ignored every per-descriptor
fcntl error, so the program went on with the token descriptor still
inheritable and said nothing about it.
It now returns an error and Execute exits on it. A descriptor the
listing named but that is no longer open is the one tolerated failure —
the listing is a snapshot, and a closed descriptor is already out of
reach of every child; anything else stops startup.
---
internal/cli/inherited_fds_linux.go | 67 +++++++++++++++++++-----
internal/cli/inherited_fds_linux_test.go | 60 +++++++++++++++++++--
internal/cli/inherited_fds_other.go | 8 +--
internal/cli/root.go | 18 ++++++-
internal/cli/root_test.go | 15 ++++++
5 files changed, 147 insertions(+), 21 deletions(-)
diff --git a/internal/cli/inherited_fds_linux.go b/internal/cli/inherited_fds_linux.go
index 020247ef7..05266927d 100644
--- a/internal/cli/inherited_fds_linux.go
+++ b/internal/cli/inherited_fds_linux.go
@@ -3,6 +3,8 @@
package cli
import (
+ "errors"
+ "fmt"
"math"
"os"
@@ -26,42 +28,81 @@ import (
// buffered in it, so nothing is consumed for an invocation that never serves.
// Standard input, output and error are left alone: a child is meant to share
// those.
-func sealInheritedDescriptors() {
+//
+// It fails closed. This is the credential handover boundary, and a process
+// that cannot say every inherited descriptor is sealed cannot say the token
+// descriptor is; the error it returns stops startup rather than letting the
+// program run on with a promise it did not keep.
+func sealInheritedDescriptors() error {
if err := unix.CloseRange(firstInheritedFD, math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err == nil {
- return
+ return nil
}
// Kernels before 5.11 do not know CLOSE_RANGE_CLOEXEC. Ask the process
// which descriptors it actually has and mark those.
- sealListedDescriptors()
+ return sealListedDescriptors(procSelfFD)
}
// firstInheritedFD is the first descriptor that is not one of the standard
// three.
const firstInheritedFD = 3
-func sealListedDescriptors() {
- dir, err := os.Open("/proc/self/fd")
+// procSelfFD is where Linux lists the descriptors a process holds. It is a
+// parameter of sealListedDescriptors only so a test can watch the walk refuse
+// a listing it cannot trust.
+const procSelfFD = "/proc/self/fd"
+
+func sealListedDescriptors(listingDir string) error {
+ dir, err := os.Open(listingDir)
if err != nil {
- return
+ return fmt.Errorf("could not open %s to seal the descriptors this process inherited: %w", listingDir, err)
}
defer func() { _ = dir.Close() }()
names, err := dir.Readdirnames(-1)
if err != nil {
- return
+ return fmt.Errorf("could not read %s to seal the descriptors this process inherited: %w", listingDir, err)
}
listing, err := sysfd.Of(dir.Fd())
if err != nil {
- return
+ return fmt.Errorf("could not seal the descriptors this process inherited: %w", err)
}
for _, name := range names {
fd, err := sysfd.Parse(name)
- if err != nil || fd.Int() < firstInheritedFD || fd == listing {
- continue
- }
- flags, err := unix.FcntlInt(fd.Uintptr(), unix.F_GETFD, 0)
if err != nil {
+ return fmt.Errorf("could not seal the descriptors this process inherited: %s holds %q, which is not a descriptor", listingDir, name)
+ }
+ if fd.Int() < firstInheritedFD || fd == listing {
continue
}
- _, _ = unix.FcntlInt(fd.Uintptr(), unix.F_SETFD, flags|unix.FD_CLOEXEC)
+ if err := sealDescriptor(fd); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// sealDescriptor marks one descriptor close-on-exec.
+//
+// A descriptor that is no longer open is the one failure that is not a
+// failure: the listing is a snapshot, and a number that has been closed since
+// it was taken is already out of reach of every child. Anything else means
+// the descriptor is open and still inheritable, which is the thing this
+// refuses to let happen quietly.
+func sealDescriptor(fd sysfd.Descriptor) error {
+ flags, err := unix.FcntlInt(fd.Uintptr(), unix.F_GETFD, 0)
+ if err != nil {
+ if errors.Is(err, unix.EBADF) {
+ return nil
+ }
+ return fmt.Errorf("could not read the flags of inherited descriptor %s: %w", fd, err)
+ }
+ if flags&unix.FD_CLOEXEC != 0 {
+ return nil
+ }
+ if _, err := unix.FcntlInt(fd.Uintptr(), unix.F_SETFD, flags|unix.FD_CLOEXEC); err != nil {
+ if errors.Is(err, unix.EBADF) {
+ return nil
+ }
+ return fmt.Errorf("could not keep inherited descriptor %s from the processes this one starts: %w", fd, err)
}
+ return nil
}
diff --git a/internal/cli/inherited_fds_linux_test.go b/internal/cli/inherited_fds_linux_test.go
index 32a0cbcf4..f4d14512e 100644
--- a/internal/cli/inherited_fds_linux_test.go
+++ b/internal/cli/inherited_fds_linux_test.go
@@ -5,6 +5,8 @@ package cli
import (
"os"
"os/exec"
+ "path/filepath"
+ "strconv"
"strings"
"testing"
@@ -31,7 +33,7 @@ func TestSealedDescriptorsDoNotReachChildren(t *testing.T) {
}
inherit(t, inheritedFD)
- sealInheritedDescriptors()
+ require.NoError(t, sealInheritedDescriptors())
out, err := exec.CommandContext(t.Context(), shell, "-c", "ls /proc/self/fd").Output() //nolint:gosec // G204: a listing of the child's own descriptors
require.NoError(t, err)
@@ -44,7 +46,7 @@ func TestSealedDescriptorsDoNotReachChildren(t *testing.T) {
func TestSealingLeavesTheDescriptorReadable(t *testing.T) {
inherit(t, inheritedFD)
- sealInheritedDescriptors()
+ require.NoError(t, sealInheritedDescriptors())
flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
require.NoError(t, err)
@@ -60,7 +62,7 @@ func TestSealingLeavesTheDescriptorReadable(t *testing.T) {
func TestSealingWithoutCloseRange(t *testing.T) {
inherit(t, inheritedFD)
- sealListedDescriptors()
+ require.NoError(t, sealListedDescriptors(procSelfFD))
flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
require.NoError(t, err)
@@ -72,7 +74,7 @@ func TestSealingLeavesTheStandardDescriptors(t *testing.T) {
shareStandardDescriptors(t)
before := standardFlags(t)
- sealInheritedDescriptors()
+ require.NoError(t, sealInheritedDescriptors())
assert.Equal(t, before, standardFlags(t))
}
@@ -124,3 +126,53 @@ func standardFlags(t *testing.T) []int {
}
return flags
}
+
+// The walk that stands in for CLOSE_RANGE_CLOEXEC says so when it cannot read
+// the listing it works from. Returning quietly would leave the caller
+// believing every inherited descriptor was sealed when none of them were.
+func TestSealingRefusesWhenItCannotListTheDescriptors(t *testing.T) {
+ err := sealListedDescriptors(filepath.Join(t.TempDir(), "no-such-listing"))
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "seal the descriptors this process inherited")
+}
+
+// A listing this process cannot make sense of is not a listing it may skip:
+// an entry that is not a descriptor number means the walk cannot say which
+// descriptors it covered.
+func TestSealingRefusesAListingItCannotRead(t *testing.T) {
+ listing := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(listing, "not-a-descriptor"), nil, 0o600))
+
+ err := sealListedDescriptors(listing)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"not-a-descriptor"`)
+}
+
+// A descriptor named by the listing but no longer open is the one failure
+// that is not one: it is already out of reach of every child. The listing is
+// a snapshot, so this happens whenever anything closes a descriptor while the
+// walk runs.
+func TestSealingAcceptsADescriptorThatHasSinceBeenClosed(t *testing.T) {
+ listing := t.TempDir()
+ closedFD := inheritedFD + 1
+ require.False(t, fdIsOpen(closedFD), "the test needs a descriptor number nothing holds")
+ require.NoError(t, os.WriteFile(filepath.Join(listing, strconv.Itoa(closedFD)), nil, 0o600))
+
+ assert.NoError(t, sealListedDescriptors(listing))
+}
+
+// The walk seals what the listing names, so a listing standing in for
+// /proc/self/fd reaches the same descriptor the real one would.
+func TestSealingSealsWhatTheListingNames(t *testing.T) {
+ inherit(t, inheritedFD)
+ listing := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(listing, strconv.Itoa(inheritedFD)), nil, 0o600))
+
+ require.NoError(t, sealListedDescriptors(listing))
+
+ flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
+ require.NoError(t, err)
+ assert.NotZero(t, flags&unix.FD_CLOEXEC)
+}
diff --git a/internal/cli/inherited_fds_other.go b/internal/cli/inherited_fds_other.go
index befa67b61..b488ba929 100644
--- a/internal/cli/inherited_fds_other.go
+++ b/internal/cli/inherited_fds_other.go
@@ -3,6 +3,8 @@
package cli
// sealInheritedDescriptors has nothing to seal where the handover it protects
-// does not happen: the agent connector runs on Linux, and elsewhere this keeps
-// the program building and starting the same way.
-func sealInheritedDescriptors() {}
+// cannot happen: a task token arrives on an inherited descriptor only on
+// Linux, which is what internal/commands/mcp_token_linux.go accepts and
+// internal/commands/mcp_token_other.go refuses. Elsewhere this keeps the
+// program building and starting the same way.
+func sealInheritedDescriptors() error { return nil }
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 65a76622e..b6cb34309 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -308,7 +308,13 @@ func Execute() {
// persistent hooks load configuration, tighten directories and may check
// for an update before any command's own RunE runs, and a connector-
// started worker arrives holding its task token on an inherited pipe.
- sealInheritedDescriptors()
+ //
+ // A process that cannot seal them cannot make that promise, so it does
+ // not start: continuing would run every hook with the token descriptor
+ // still there for a child to inherit, and say nothing about it.
+ if err := sealInheritedDescriptors(); err != nil {
+ os.Exit(reportStartupFailure(os.Stderr, err))
+ }
cmd := NewRootCmd()
@@ -548,6 +554,16 @@ func reportWireError(w io.Writer, err error) int {
return output.ExitCodeFor(apiErr.Code)
}
+// reportStartupFailure says on stderr why the program will not run at all.
+// It comes before cobra has parsed anything, so there is no command to ask
+// for an output format and no envelope to fill; stderr also keeps it clear of
+// a command whose stdout speaks a wire protocol.
+func reportStartupFailure(w io.Writer, err error) int {
+ apiErr := output.AsError(err)
+ fmt.Fprintln(w, "Error: "+richtext.SanitizeSingleLine(apiErr.Message))
+ return output.ExitCodeFor(apiErr.Code)
+}
+
// jqUsable reports whether a filter parses and compiles. Only these failures
// are knowable before any output is produced, which is what makes clearing the
// filter safe: a filter that fails at runtime may already have written.
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
index 48304f577..88ce7c965 100644
--- a/internal/cli/root_test.go
+++ b/internal/cli/root_test.go
@@ -2,6 +2,7 @@ package cli
import (
"bytes"
+ "errors"
"os"
"runtime"
"testing"
@@ -409,6 +410,20 @@ func TestReportWireError(t *testing.T) {
assert.Equal(t, "Error: badgateway injected\n", buf.String())
}
+// TestReportStartupFailure pins what a refused startup looks like: the
+// program has not parsed a command yet, so there is no output format to
+// honor and no envelope to fill — one sanitized line on stderr and a
+// non-zero exit. sealInheritedDescriptors is the only caller: a process that
+// cannot keep an inherited credential from its children does not run.
+func TestReportStartupFailure(t *testing.T) {
+ var buf bytes.Buffer
+
+ code := reportStartupFailure(&buf, errors.New("could not open /proc/self/fd to seal the descriptors this process inherited"))
+
+ assert.Equal(t, "Error: could not open /proc/self/fd to seal the descriptors this process inherited\n", buf.String())
+ assert.NotEqual(t, output.ExitOK, code)
+}
+
// TestResolveProfileMayCreatePassesUnknownName: a command that registers the
// named profile itself (auth login --with-token) receives the name; every
// other command is still refused an unknown --profile / BASECAMP_PROFILE.
From 01806f04b8465176b73da38a817ceb77d6299203 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Fri, 18 Sep 2026 08:36:14 +0200
Subject: [PATCH 32/33] Read a task token only where the descriptor it arrives
on is sealed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The reader was built for every Unix while the seal was built for Linux
alone, so on macOS and the BSDs a worker could hand over a task token on
a pipe that nothing had marked close-on-exec — inheritable through every
hook that runs before the command reads it, which is the whole reason
the token travels on a descriptor rather than in a file or the
environment.
The handover is now Linux-only, matching the seal: the connector runs on
Linux, and sealing code for four more platforms would be code we have no
way to exercise at the boundary where being wrong is worst. Elsewhere
--connect-state is refused, and says why.
A comment is not a constraint, so the two packages' build tags are
cross-checked: TestTheTokenIsOnlyReadWhereItIsSealed asks go/build, for
each platform we release, whether the file that seals and the file that
refuses are compiled together, and fails if a platform ever reads a
token it does not seal.
---
internal/cli/token_handover_platforms_test.go | 69 +++++++++++++++++++
internal/commands/mcp_connect_test.go | 2 +-
...est.go => mcp_connect_token_linux_test.go} | 2 +-
.../{mcp_token_unix.go => mcp_token_linux.go} | 19 +++--
internal/commands/mcp_token_other.go | 15 ++--
5 files changed, 95 insertions(+), 12 deletions(-)
create mode 100644 internal/cli/token_handover_platforms_test.go
rename internal/commands/{mcp_connect_token_unix_test.go => mcp_connect_token_linux_test.go} (99%)
rename internal/commands/{mcp_token_unix.go => mcp_token_linux.go} (81%)
diff --git a/internal/cli/token_handover_platforms_test.go b/internal/cli/token_handover_platforms_test.go
new file mode 100644
index 000000000..554d8cb22
--- /dev/null
+++ b/internal/cli/token_handover_platforms_test.go
@@ -0,0 +1,69 @@
+package cli
+
+import (
+ "go/build"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// sealingFile is the only implementation that keeps an inherited descriptor
+// from the processes this one starts; refusalFile is the readTaskToken that
+// refuses a task token outright. Named rather than inferred, so this test
+// asks about the code rather than about a build tag it repeats.
+const (
+ sealingDir = "."
+ sealingFile = "inherited_fds_linux.go"
+ handoverDir = "../commands"
+ refusalFile = "mcp_token_other.go"
+)
+
+// releasePlatforms are the targets `make build-all` produces, plus the other
+// Unix ones a `go build` is expected to work on.
+var releasePlatforms = []struct{ goos, goarch string }{
+ {"linux", "amd64"},
+ {"linux", "arm64"},
+ {"darwin", "arm64"},
+ {"darwin", "amd64"},
+ {"freebsd", "amd64"},
+ {"openbsd", "amd64"},
+ {"netbsd", "amd64"},
+ {"windows", "amd64"},
+}
+
+// A platform reads a task token off an inherited descriptor exactly where it
+// seals inherited descriptors.
+//
+// These are two build constraints in two packages answering one question, and
+// they are only ever right together: a platform that accepts the handover
+// without the seal leaves the token descriptor inheritable through every hook
+// that runs before the command reads it, which is the guarantee the handover
+// exists for. The pair drifted once already — the reader was built for every
+// Unix while the seal was Linux-only — so it is checked rather than
+// remembered.
+func TestTheTokenIsOnlyReadWhereItIsSealed(t *testing.T) {
+ for _, platform := range releasePlatforms {
+ t.Run(platform.goos+"/"+platform.goarch, func(t *testing.T) {
+ seals := compilesFile(t, sealingDir, platform.goos, platform.goarch, sealingFile)
+ refuses := compilesFile(t, handoverDir, platform.goos, platform.goarch, refusalFile)
+
+ assert.Equal(t, seals, !refuses,
+ "%s/%s compiles %s (seals inherited descriptors) = %v, and compiles %s (refuses the token handover) = %v; it must do exactly one",
+ platform.goos, platform.goarch, sealingFile, seals, refusalFile, refuses)
+ })
+ }
+}
+
+// compilesFile reports whether a build for this platform includes the file.
+func compilesFile(t *testing.T, dir, goos, goarch, file string) bool {
+ t.Helper()
+ context := build.Default
+ context.GOOS = goos
+ context.GOARCH = goarch
+ context.CgoEnabled = false
+ pkg, err := context.ImportDir(dir, 0)
+ require.NoError(t, err)
+ return slices.Contains(pkg.GoFiles, file)
+}
diff --git a/internal/commands/mcp_connect_test.go b/internal/commands/mcp_connect_test.go
index 0c5f1f5b8..fbdca71a2 100644
--- a/internal/commands/mcp_connect_test.go
+++ b/internal/commands/mcp_connect_test.go
@@ -1,4 +1,4 @@
-//go:build unix
+//go:build linux
package commands
diff --git a/internal/commands/mcp_connect_token_unix_test.go b/internal/commands/mcp_connect_token_linux_test.go
similarity index 99%
rename from internal/commands/mcp_connect_token_unix_test.go
rename to internal/commands/mcp_connect_token_linux_test.go
index 9878df53c..bee398b8d 100644
--- a/internal/commands/mcp_connect_token_unix_test.go
+++ b/internal/commands/mcp_connect_token_linux_test.go
@@ -1,4 +1,4 @@
-//go:build unix
+//go:build linux
package commands
diff --git a/internal/commands/mcp_token_unix.go b/internal/commands/mcp_token_linux.go
similarity index 81%
rename from internal/commands/mcp_token_unix.go
rename to internal/commands/mcp_token_linux.go
index 4e1715231..d6829f6fc 100644
--- a/internal/commands/mcp_token_unix.go
+++ b/internal/commands/mcp_token_linux.go
@@ -1,4 +1,4 @@
-//go:build unix
+//go:build linux
package commands
@@ -26,11 +26,18 @@ const firstTokenFD = 3
// exists at a path, in argv or in the environment; once read, the descriptor
// is gone too, and nothing this process starts can inherit it.
//
-// Descriptors below firstTokenFD are refused. Only a pipe or a socket is
-// taken, and anything else is left
-// exactly as it was — not read, not closed: a regular file would be the token
-// at a path, and a wrong number could name a descriptor this process already
-// uses.
+// This file is built for Linux alone, and shares that constraint with
+// internal/cli's inherited_fds_linux.go on purpose: a credential may arrive
+// on an inherited descriptor only where startup has already sealed every
+// inherited descriptor against the children the pre-command hooks start.
+// Everywhere else mcp_token_other.go refuses the handover, and
+// TestTheTokenIsOnlyReadWhereItIsSealed holds the two constraints together.
+//
+// Descriptors below sysfd.FirstNonStandard are refused: stdin and stdout are
+// the MCP wire, and stderr is the log. Only a pipe or a socket is taken, and
+// anything else is left exactly as it was — not read, not closed: a regular
+// file would be the token at a path, and a wrong number could name a
+// descriptor this process already uses.
//
// The read ends at the first newline or at end of file, and is bounded in
// size and in time, so a write end left open somewhere cannot hang startup. A
diff --git a/internal/commands/mcp_token_other.go b/internal/commands/mcp_token_other.go
index 2b1953c42..6a11677df 100644
--- a/internal/commands/mcp_token_other.go
+++ b/internal/commands/mcp_token_other.go
@@ -1,4 +1,4 @@
-//go:build !unix
+//go:build !linux
package commands
@@ -7,8 +7,15 @@ import (
"github.com/basecamp/basecamp-cli/internal/sysfd"
)
-// readTaskToken is refused where the connector cannot run: its ledger's
-// privacy cannot be established off Unix, so no worker is started there.
+// readTaskToken is refused wherever the descriptor carrying the token cannot
+// be kept from the processes this one starts. Only Linux seals the
+// descriptors this process inherited, in internal/cli's
+// inherited_fds_linux.go, so only Linux may be handed a credential on one:
+// accepting a token where nothing seals it would leave it inheritable through
+// every hook that runs before this command, which is the whole point of
+// handing it over on a descriptor instead of in a file or an environment.
+//
+// The connector runs on Linux, so this refuses nothing that works today.
func readTaskToken(sysfd.Descriptor) (string, error) {
- return "", output.ErrUsage("--connect-state is only available on Unix, where the connector runs")
+ return "", output.ErrUsage("--connect-state is only available on Linux, where an inherited descriptor can be kept from the processes this one starts")
}
From c7a71af5871651bec53effa2b2278ea2a150b394 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Fri, 18 Sep 2026 08:36:14 +0200
Subject: [PATCH 33/33] Draw the line above standard I/O once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two constants, both 3, in two packages either side of the credential
handover: firstInheritedFD said which descriptors startup seals, and
firstTokenFD said which descriptors a task token may arrive on. They
answer the same question and are only ever right together — a token
accepted below where the seal begins would be a credential nothing had
sealed — so the line is drawn once, in sysfd, where both sides already
look for what a descriptor is.
---
internal/cli/inherited_fds_linux.go | 8 ++------
internal/commands/mcp_token_linux.go | 8 ++------
internal/sysfd/sysfd.go | 16 +++++++++++++---
3 files changed, 17 insertions(+), 15 deletions(-)
diff --git a/internal/cli/inherited_fds_linux.go b/internal/cli/inherited_fds_linux.go
index 05266927d..4f0011c55 100644
--- a/internal/cli/inherited_fds_linux.go
+++ b/internal/cli/inherited_fds_linux.go
@@ -34,7 +34,7 @@ import (
// descriptor is; the error it returns stops startup rather than letting the
// program run on with a promise it did not keep.
func sealInheritedDescriptors() error {
- if err := unix.CloseRange(firstInheritedFD, math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err == nil {
+ if err := unix.CloseRange(uint(sysfd.FirstNonStandard), math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err == nil {
return nil
}
// Kernels before 5.11 do not know CLOSE_RANGE_CLOEXEC. Ask the process
@@ -42,10 +42,6 @@ func sealInheritedDescriptors() error {
return sealListedDescriptors(procSelfFD)
}
-// firstInheritedFD is the first descriptor that is not one of the standard
-// three.
-const firstInheritedFD = 3
-
// procSelfFD is where Linux lists the descriptors a process holds. It is a
// parameter of sealListedDescriptors only so a test can watch the walk refuse
// a listing it cannot trust.
@@ -70,7 +66,7 @@ func sealListedDescriptors(listingDir string) error {
if err != nil {
return fmt.Errorf("could not seal the descriptors this process inherited: %s holds %q, which is not a descriptor", listingDir, name)
}
- if fd.Int() < firstInheritedFD || fd == listing {
+ if fd < sysfd.FirstNonStandard || fd == listing {
continue
}
if err := sealDescriptor(fd); err != nil {
diff --git a/internal/commands/mcp_token_linux.go b/internal/commands/mcp_token_linux.go
index d6829f6fc..dbbbe30a3 100644
--- a/internal/commands/mcp_token_linux.go
+++ b/internal/commands/mcp_token_linux.go
@@ -17,10 +17,6 @@ import (
"github.com/basecamp/basecamp-cli/internal/sysfd"
)
-// firstTokenFD is the lowest descriptor a task token may arrive on: below it
-// are stdin and stdout, which are the MCP wire, and stderr, which is the log.
-const firstTokenFD = 3
-
// readTaskToken reads the task token from an inherited descriptor and closes
// it. The connector hands the token over as the read end of a pipe, so it never
// exists at a path, in argv or in the environment; once read, the descriptor
@@ -44,8 +40,8 @@ const firstTokenFD = 3
// sender writes "token\n", or closes its end after the token.
func readTaskToken(descriptor sysfd.Descriptor) (string, error) {
fd := descriptor.Int()
- if fd < firstTokenFD {
- return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be %d or above", fd, firstTokenFD))
+ if descriptor < sysfd.FirstNonStandard {
+ return "", output.ErrUsage(fmt.Sprintf("--connect-token-fd %d is standard I/O; the token descriptor must be %s or above", fd, sysfd.FirstNonStandard))
}
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
diff --git a/internal/sysfd/sysfd.go b/internal/sysfd/sysfd.go
index 52f7a76a4..6990f1ea1 100644
--- a/internal/sysfd/sysfd.go
+++ b/internal/sysfd/sysfd.go
@@ -17,11 +17,21 @@ import (
// the only ways to make one, so anything holding a Descriptor holds a number
// that converts safely.
//
-// It says nothing about which descriptors are appropriate for a given job.
-// Whether standard input may be read as a token, for instance, belongs where
-// the flag is read, not here.
+// It says nothing about whether a descriptor is appropriate for a given job.
+// Whether a task token may be read from a pipe rather than a file, for
+// instance, belongs where the flag is read, not here.
type Descriptor int
+// FirstNonStandard is the first descriptor that is not standard input,
+// output or error — the one fact about which descriptors are which that the
+// two sides of the credential handover both need, and are only ever right
+// about together. Startup seals every descriptor from here up, because below
+// it are the three a child is meant to share; the mcp command refuses a task
+// token below it, because there the token would be arriving on the MCP wire
+// or the log. One line, drawn once: a token accepted below where the seal
+// starts would be a credential nothing had sealed.
+const FirstNonStandard Descriptor = 3
+
// maxDescriptor is the portable ceiling: int is 32 bits on a 32-bit build, so
// a number that fits in int64 is not necessarily one this process can hold.
const maxDescriptor = math.MaxInt32