Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
615d2b2
Speak to the person in the thread, and catch a worker that can't start
robzolkos Sep 29, 2026
d71adb9
Guide basecamp connect setup like a conversation
robzolkos Sep 29, 2026
a104bea
Setup says how to run the agent instead of offering a background service
robzolkos Sep 30, 2026
8a6cb3b
Hold new work from the next launch, not the next pass
robzolkos Sep 30, 2026
2556473
Check the worker when the connector starts, not after two failed requ…
robzolkos Sep 30, 2026
78df3de
Keep an explicit --account when guided setup switches to the agent pr…
robzolkos Sep 30, 2026
1b863a8
Check the credential when guided setup resumes a finished setup
robzolkos Sep 30, 2026
8472ede
Check an acp worker in guided setup, as doctor does
robzolkos Sep 30, 2026
93f948d
Record why work is held only while it still is
robzolkos Sep 30, 2026
dba5421
Don't say a worker couldn't start when it had already started
robzolkos Sep 30, 2026
47ebe49
Guided setup: refuse unsupported platforms first, re-read before rebu…
robzolkos Sep 30, 2026
376b105
Count a session that can't be prepared toward holding new work
robzolkos Sep 30, 2026
5de21a8
A worker check only decides for the hold it was started for
robzolkos Sep 30, 2026
d2d61db
Preflight probes: end the whole tree on timeout, and don't call a bro…
robzolkos Sep 30, 2026
c06fc25
A task launched before failures doesn't clear the hold they made
robzolkos Sep 30, 2026
3d12d3f
End a probe's children when its launcher exits first
robzolkos Sep 30, 2026
be57bd2
Guided setup: check the agent profile's base URL, and describe setup …
robzolkos Sep 30, 2026
68fd80d
Don't tell people work wasn't done when it may have been
robzolkos Sep 30, 2026
6b69c73
Ask whether Claude is logged in with the host's settings off
robzolkos Sep 30, 2026
dbdc790
A launch that worked answers only the failures before it
robzolkos Sep 30, 2026
bc3b8bd
Count a failed start before giving its worker slot back
robzolkos Sep 30, 2026
f3cb212
Clean up a probe's group only while it is still the probe's
robzolkos Sep 30, 2026
2e82891
Refuse to save guided setup for an agent it didn't show
robzolkos Sep 30, 2026
ef8bdc6
Say the connector is running only if its lock's holder is
robzolkos Sep 30, 2026
5758a16
Start the reused-pid test's stranger with a context, as lint requires
robzolkos Sep 30, 2026
440f094
Probe cleanup signals only the probe's group, by what it recorded whi…
robzolkos Sep 30, 2026
c2b5874
A failure that settles after a later launch worked isn't counted
robzolkos Sep 30, 2026
0ec0e17
Guided setup's retry instructions name the profile it was working on
robzolkos Sep 30, 2026
dd201d8
Guided setup doesn't save over a setup written while it was asking
robzolkos Sep 30, 2026
0ac540d
Guided setup moves aside only the connect.json it asked about
robzolkos Sep 30, 2026
d5d72aa
Say a finished request reported no reply, not that none was posted
robzolkos Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion internal/auth/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,11 +430,18 @@ func (m *Manager) agentMintRefusal(resp *http.Response, body []byte, mint *agent
// fetch its secret again for one is advice that cannot help.
bareUnauthorized := code == "" && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden)
if clientRefusalCodes[code] || bareUnauthorized {
return m.agentRemedy(output.ErrAuth("Minting an agent token was refused ("+detail+")"), mint.clientID, mint.scope)
refused := output.ErrAuth("Minting an agent token was refused (" + detail + ")")
refused.Cause = ErrAgentCredentialRefused
return m.agentRemedy(refused, mint.clientID, mint.scope)
}
return statusFailure("minting an agent token: "+detail, resp)
}

// ErrAgentCredentialRefused is the cause of a mint the token endpoint
// refused for the credentials themselves: the agent was disconnected in
// Basecamp, or connected on another computer, which replaced its secret.
var ErrAgentCredentialRefused = errors.New("the agent's credential was refused")

// clientRefusalCodes are the RFC 6749 §5.2 codes that say THE CREDENTIALS
// PRESENTED are wrong, which is the only thing piping the secret in again
// can repair.
Expand Down
106 changes: 64 additions & 42 deletions internal/commands/auth_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,49 +88,9 @@ client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P
return output.ErrUsage("Invalid scope. Use 'read' or 'full'")
}

target, err := resolveAgentConnectProfile(app)
if err != nil {
return err
}

w := cmd.OutOrStdout()
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark()))

var isDefault bool
ctx, stop := loginContext(cmd)
result, err := app.Auth.ConnectAgent(ctx, auth.AgentConnectOptions{
DeviceName: connectDeviceName(deviceName),
SoftwareName: softwareName,
Scope: scope,
NoBrowser: noBrowser,
Local: local,
Logger: func(msg string) { fmt.Fprintln(w, msg) },
Progress: w,
BeforeStore: func(conn *auth.AgentConnection) error {
registered, commitErr := target.commit(app, conn)
isDefault = registered
return commitErr
},
return connectAgentProfile(cmd, app, agentConnectFlags{
deviceName: deviceName, softwareName: softwareName, scope: scope, noBrowser: noBrowser, local: local,
})
err = loginOutcome(ctx, err, w, r)
stop()
if err != nil {
return err
}

fmt.Fprintln(w)
fmt.Fprintln(w, r.Success.Render(fmt.Sprintf("Connected profile %q to a Basecamp agent", target.name)))
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf("Profile: %s · Account: %s · Access: %s · Token: minted on demand, no refresh token",
target.name, result.AccountID, result.Scope)))
if target.existing == nil {
line := fmt.Sprintf("Created profile %q for account %s", target.name, result.AccountID)
if isDefault {
line += " (default)"
}
fmt.Fprintln(w, r.Muted.Render(line))
}
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf("Check it any time: basecamp auth status -P %s", target.name)))
return nil
},
}

Expand All @@ -143,6 +103,68 @@ client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P
return cmd
}

// agentConnectFlags are the choices an agent connection takes.
type agentConnectFlags struct {
deviceName string
softwareName string
scope string
noBrowser bool
local bool

// quiet leaves out what was stored, for the guided setup, which says
// in one line of its own which agent this computer is now connected as.
quiet bool
}

// connectAgentProfile runs the agent-connection handshake for the active
// profile and says what it stored. Both `auth agent connect` and the guided
// `connect setup` run it, so a person sees one connection either way.
func connectAgentProfile(cmd *cobra.Command, app *appctx.App, f agentConnectFlags) error {
target, err := resolveAgentConnectProfile(app)
if err != nil {
return err
}

w := cmd.OutOrStdout()
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark()))

var isDefault bool
ctx, stop := loginContext(cmd)
result, err := app.Auth.ConnectAgent(ctx, auth.AgentConnectOptions{
DeviceName: connectDeviceName(f.deviceName),
SoftwareName: f.softwareName,
Scope: f.scope,
NoBrowser: f.noBrowser,
Local: f.local,
Logger: func(msg string) { fmt.Fprintln(w, msg) },
Progress: w,
BeforeStore: func(conn *auth.AgentConnection) error {
registered, commitErr := target.commit(app, conn)
isDefault = registered
return commitErr
},
})
err = loginOutcome(ctx, err, w, r)
stop()
if err != nil || f.quiet {
return err
}

fmt.Fprintln(w)
fmt.Fprintln(w, r.Success.Render(fmt.Sprintf("Connected profile %q to a Basecamp agent", target.name)))
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf("Profile: %s · Account: %s · Access: %s · Token: minted on demand, no refresh token",
target.name, result.AccountID, result.Scope)))
if target.existing == nil {
line := fmt.Sprintf("Created profile %q for account %s", target.name, result.AccountID)
if isDefault {
line += " (default)"
}
fmt.Fprintln(w, r.Muted.Render(line))
}
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf("Check it any time: basecamp auth status -P %s", target.name)))
return nil
}

// connectDeviceName is what the approval page calls this computer: what
// the operator named, or this host's own name. A hostname that cannot be
// read leaves it empty, and the flow refuses with the flag to pass — the
Expand Down
82 changes: 69 additions & 13 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,17 @@ type connectSetupFlags struct {
worker string
parallel int
deadline time.Duration

// guided is the guided setup running this one: a setup that passes says
// so in a line, since the guided summary names the agent, its owner and
// its projects. A failure still lists every check. It only ever makes a
// connect.json, so it refuses one that appeared while it was asking.
guided bool
// shownAgent and shownAccount are the agent and account guided setup
// showed the person; setup refuses to save for any other (0 and "" when
// not guided).
shownAgent int64
shownAccount string
}

func newConnectSetupCmd() *cobra.Command {
Expand All @@ -291,11 +302,12 @@ On the bot-user path pass --expect-identity to setup as well, so it can prove
the login is the bot and not you; later runs remember it.

Operator. The person whose instructions the agent follows, keyed on Person
id. Name them by their own profile (--operator-profile, which proves who
they are), or by id (--operator), which the agent must be able to read —
Basecamp refuses that read to an Agent identity today, so on the agent
connection path use --operator-profile. With neither, setup keeps the operator
connect.json already has; on a first setup one of the two is required.
id. A personal agent's operator is its owner, whom Basecamp names in the
agent's own profile, so no flag is needed for one. Otherwise name them by
their own profile (--operator-profile, which proves who they are), or by id
(--operator), which the agent must be able to read. With neither, setup keeps
the operator connect.json already has; on a first setup of an agent with no
owner, one of the two is required.

Trust. operator (default): the operator alone. allowlist: the operator and
the people passed with --allow. project: the operator and any non-client
Expand Down Expand Up @@ -328,18 +340,33 @@ as by any command.

Run setup again to change any of it; what you do not pass is kept.

Guided. In a terminal, with none of the flags that set policy, setup walks
you through instead: it connects this computer to your agent when it is not
(or no longer) connected, takes your agent's owner as the operator, asks
which of your agent's projects it works in (all of them by default), and
writes connect.json, offering to set it up again when the one there can't
be used or is for another agent. It ends by saying how to start the
connector: in the folder it should work in, left running. Run it again any
time: it takes the next step, or says everything is set.

Examples:
basecamp connect setup # guided, in a terminal
basecamp auth agent connect -P agent
basecamp connect setup -P agent --serve 12345 # a personal agent: its owner operates it
basecamp connect setup -P agent --operator-profile me --serve 12345
basecamp connect setup -P agent --operator-profile me --trust allowlist --allow 111 --allow 222
basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --serve 12345
basecamp connect setup -P agent --class 12345=internal --deadline 90m`,
Args: cobra.NoArgs,
Annotations: map[string]string{AnnotationProfileMayCreate: "true"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}
if connectSetupGuided(cmd, app) {
return runGuidedConnectSetup(cmd, app, &f)
}
return runConnectSetup(cmd, app, &f)
},
}
Expand Down Expand Up @@ -419,16 +446,23 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
if existing.Profile != name {
return output.ErrUsage(fmt.Sprintf("%s names profile %q, not %q", path, existing.Profile, name))
}
// Guided setup makes connect.json only where there was none. One written
// while it was asking is another setup's, and its trust may not be what
// guided setup told the person: it is left as it is.
if f.guided && exists {
return output.ErrUsageHint("This agent was set up by another command while setup was asking about it, so nothing was changed",
runGuidedSetupAgain(name))
}

// Everything refusable without the network is refused first.
next, err := setup.Apply(existing, changes)
if err != nil {
return output.ErrUsage(err.Error())
}
if operatorID == 0 && f.operatorProfile == "" && existing.Trust.OperatorID == 0 {
return output.ErrUsageHint("Setup needs to know who the operator is",
"Pass --operator-profile <your profile> (or --operator <your person id>). The operator is the person the agent takes instructions from, and is never guessed.")
}
// With no operator named or recorded, a personal agent's operator is the
// person it works for, which Basecamp says in the agent's own profile.
// Anyone else is never guessed: that is refused below, after the read.
operatorFromOwner := operatorID == 0 && f.operatorProfile == "" && existing.Trust.OperatorID == 0
var operatorMgr *operatorProfile
if f.operatorProfile != "" {
if operatorMgr, err = operatorProfileManager(ctx, app, f.operatorProfile); err != nil {
Expand Down Expand Up @@ -497,6 +531,13 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
if err != nil {
return output.ErrAuth(fmt.Sprintf("Could not read who profile %q is in account %s: %s", name, accountID, setup.ErrorText(err)))
}
// Guided setup asked its questions about one agent; a credential stored
// under the profile since, for another, is not what the person answered
// for (Codex on #794).
if f.shownAgent != 0 && (me.ID != f.shownAgent || accountID != f.shownAccount) {
return output.ErrUsageHint("This computer was connected to a different agent while setup was asking about it, so nothing was set up",
"Run basecamp connect setup again.")
}
identityCheck, err := checkConnectIdentity(ctx, app, client, kind, creds.OAuthType, me, expect)
if err != nil {
return err
Expand All @@ -520,14 +561,25 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
trust.Recorded = existing.Trust
}
people := setup.Reader(reader)
if operatorMgr != nil {
switch {
case operatorFromOwner:
owner, ok, err := readAgentOwner(ctx, client.ForAccount(accountID), kind)
if err != nil {
return output.ErrAuth(fmt.Sprintf("Could not read who agent %q works for: %s", me.Name, setup.ErrorText(err)))
}
if !ok {
return errOperatorRequired()
}
trust.Operator = owner
trust.OperatorIsOwner = true
case operatorMgr != nil:
op, opReader, err := resolveOperatorProfile(ctx, operatorMgr, f.operatorProfile, accountID)
if err != nil {
return err
}
trust.Operator = op
people = opReader
} else {
default:
if operatorID == 0 {
operatorID = existing.Trust.OperatorID
}
Expand Down Expand Up @@ -608,7 +660,11 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
if !report.Ready() {
return errConnectorNotReady(report)
}
if styled {
switch {
case styled && f.guided:
renderGuidedChecks(w, report.Checks())
return nil
case styled:
renderChecksStyled(w, title, summary)
fmt.Fprintf(w, " connect.json written: %s\n\n", richtext.SanitizeSingleLine(path))
return nil
Expand Down
25 changes: 18 additions & 7 deletions internal/commands/connect_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ func newConnectDoctorCmd() *cobra.Command {
Short: "Check what the connector needs to run",
Long: `Check the connector for a set-up profile: connect.json, the token, the agent's
identity, the stream ticket mint, the account feed, the ledger (its gaps, open
losses, hold and messages waiting for a person), the
worker the driver runs — the worker's own CLI on PATH under the spawn driver,
the pinned ACP adapter in the connector's adapters directory under the acp
driver, and the adapter's own refusal of configuration on this machine that
the connector cannot switch off, checked in the directory this command runs
in — and a handshake with the agent's Basecamp MCP server, started with a worker's
losses, hold and messages waiting for a person), the worker the driver runs —
under the spawn driver, the worker's own CLI started as the connector starts
it and asked, without any work or model call, whether it runs, knows the
connector's flags and is logged in; under the acp driver, the pinned ACP
adapter in the connector's adapters directory, and the adapter's own refusal
of configuration on this machine that the connector cannot switch off,
checked in the directory this command runs in — and a handshake with the agent's Basecamp MCP server, started with a worker's
environment (without the basecamp_connect domain, which only a dispatched
task's token opens).

Expand Down Expand Up @@ -79,7 +80,7 @@ func runConnectDoctor(cmd *cobra.Command, _ []string) error {
)
}
checks = append(checks, ledgerChecks(ctx, p)...)
checks = append(checks, workerBinaryChecks(p.file)...)
checks = append(checks, workerChecks(ctx, p.file)...)
checks = append(checks, mcpHandshakeCheck(ctx, p.name))

result := summarizeChecks(asDoctorChecks(checks))
Expand Down Expand Up @@ -186,6 +187,16 @@ func workerBinaries(file setup.File) []string {
return []string{file.WorkerName()}
}

// workerChecks is the worker as the connector would start it: the spawn
// driver's preflight, where the driver has one, and otherwise where its
// binary is found.
func workerChecks(ctx context.Context, file setup.File) []setup.Check {
if p, ok := connectWorkerPreflight(ctx, file); ok {
return preflightChecks(p)
}
return workerBinaryChecks(file)
}

// workerBinaryChecks looks for the worker where the driver that runs it
// looks. The spawn driver runs the worker's own CLI, which is on PATH. The
// acp driver runs a pinned adapter out of the connector's own npm prefix
Expand Down
15 changes: 15 additions & 0 deletions internal/commands/connect_operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,20 @@ func runConnectStatus(cmd *cobra.Command, shadow bool) error {
return p.app.OK(report, output.WithSummary(connectStatusSummary(report)))
}

// notTakingWork is why the connector stopped taking work, when it did: its
// worker could not start (connector.StartFailuresToHold).
func notTakingWork(s connector.Status) (string, bool) {
if s.Connection == nil || s.Connection.State != connector.ConnectionNotTakingWork {
return "", false
}
return s.Connection.Detail, true
}

func connectStatusSummary(r connectStatusReport) string {
parts := []string{}
if _, ok := notTakingWork(r.Status); ok {
parts = append(parts, "not taking work")
}
if r.Status.Hold != nil {
parts = append(parts, "held")
}
Expand All @@ -351,6 +363,9 @@ func renderConnectStatus(w io.Writer, r connectStatusReport) {
title += " (shadow)"
}
fmt.Fprintf(w, "%s\n\n", title)
if why, ok := notTakingWork(s); ok {
fmt.Fprintf(w, " Not taking work: %s. %s\n\n", clean(why), connector.NotTakingWorkFix)
}

switch {
case r.LockHolder == nil:
Expand Down
14 changes: 14 additions & 0 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,20 @@ func connectDispatcherOptions(d connectDispatch) connector.DispatcherOptions {
Lines: d.Lines,
Logger: d.Logger,
StillRunning: connector.DefaultStillRunning,
Preflight: workerPreflight(d.Driver),
}
}

// workerPreflight is the driver's preflight, for a dispatcher that stopped
// taking work because its worker could not start; nil for a driver without
// one.
func workerPreflight(d driver.Driver) func(context.Context) driver.Preflight {
p, ok := d.(driver.Preflighter)
if !ok {
return nil
}
return func(ctx context.Context) driver.Preflight {
return p.Preflight(ctx, connector.DefaultPolicy())
}
}

Expand Down
Loading
Loading