Skip to content

Add basecamp connect setup: record trust, write connect.json, check readiness - #731

Merged
jorgemanrubia merged 19 commits into
mainfrom
connect-setup
Sep 17, 2026
Merged

jorgemanrubia merged 19 commits into
mainfrom
connect-setup

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

The connector has admission, which reads trust and routes from connect.json, and a way to connect an agent (basecamp auth agent connect). Nothing writes connect.json, and nothing checks that a connected profile can actually run a connector. Today that means hand-writing the trust anchor and finding out at run time that Basecamp refuses the agent's reads.

Tracked in 16 basecamp connect setup.

Why

connect.json is the only authority for who may drive the agent and which directory a project's work runs in. It has to come from something that verifies the identities in it and refuses a copy someone else could have edited. It is also where basecamp connect starts, so this adds the command group.

Scope: setup on a connected profile

basecamp connect setup -P <profile> works on a profile that already holds the agent's credential. It never obtains, changes or removes a credential. A profile with none is refused, and the refusal names the command that connects it:

basecamp auth agent connect -P agent                              # an Agent person
basecamp connect setup -P agent --operator-profile me --route 12345=~/Work/app

A first version also ran the connection itself and undid it on failure. Six review rounds kept finding the same kinds of problem there: locks released before the undo, a concurrent login's credential deleted by key, prose and a JSON envelope sharing stdout, and a stale account after the child step. A design pass over the invariants split it out. Setup running the credential step follows in its own PR, once the credential store can compare-and-delete what a run stored.

What changes

  • Account. The account is the one the agent's profile is bound to. It is never taken from a config-wide default, and an explicit --account naming another account is refused.

  • Identity. An Agent credential must read back as an Agent person with a positive id. A bot login (--expect-identity, v1's path) must be the pinned identity. On a rerun, the agent and account must match what connect.json records.

  • Trust. Keyed on Person id, and never guessed.

    • A first setup names the operator, either by their own profile (--operator-profile, whose credential proves who they are) or by --operator <id>.
    • The operator and every --allow id go through one check before anything is written: a person in the account, not the agent, not an Agent, not a client. Person reads go through the operator's credential when there is one, since Basecamp refuses them to an Agent today.
    • An unverifiable id is refused, unless connect.json already records it in the same role.
  • connect.json lives at <config>/connect/<profile>/connect.json. Trust and routes are written in admission's own types, and every write is re-parsed through admission.ParsePolicy + Validate.

    • It also records watch_completions per route, class, driver (spawn), concurrency (2), deadline (45m), worktrees, and the agent's person id, kind and canonical account id.
    • Rerunning setup changes only what is passed, and every setting can be reversed: --remove-route, --no-watch-completions, --class <id>=, --trust operator, --worktrees=false.
  • Private file. connect.json is written 0600 in 0700 directories, atomically, under a per-profile setup lock. It is refused on read, and never replaced on write, in these cases:

    • the file, the config directory, connect/ or the profile directory is a symlink, not regular, owned by another user, or writable by group or others;
    • any ancestor up to /, as written or as its symlinks resolve, belongs to someone other than root or this user, or is writable by others without being sticky.

    Where ownership can't be verified (Windows), setup refuses. Unknown, duplicate or non-canonical keys are refused, and so is trailing data.

  • Locking. The last look at the credential and the write are one step under that credential key's own cross-process lock, which every login, refresh, import and logout already takes, so no command can replace the profile's credential between them. setup.Save takes the held-lock value, so a write without the lock does not compile. Where the configuration directory's filesystem cannot lock at all — no writable lock directory, no flock — setup refuses with lock_unavailable and changes nothing, rather than running unsynchronized as an ordinary credential operation would. Another setup or another command holding the profile is reported as busy; both exit with the rate-limit status.

  • Bound to the identity. connect.json records the agent's person id, kind and pinned identity, and File.VerifyAgent refuses a file that does not describe the identity a profile's credential authenticates as. Setup runs it inside the locked section; the connector runs it at start-up, so a credential replaced afterwards stops the connector instead of letting it act as the wrong agent.

  • Checks, run as the agent: token, identity, trust, scope (only a granted full passes), stream-ticket mint, and each routed project's project and project-people reads.

    • A refused read under an Agent identity says, in words, that Basecamp refuses these reads to Agent identities today (47 Admission's reads are refused under an Agent identity in bc3). The check also names the bot-user path.
    • Readiness is derived from the checks, never stored. connect.json is written only when every check passes.
  • Exit status.

    • usage: refused input.
    • auth: no credential, an unreadable one, or one that isn't the agent it should be. Every credential conflict names the command that fixes it.
    • busy / lock_unavailable: another command holds the profile, or the host cannot lock. Nothing was changed.
    • not_ready: a readiness check failed, and nothing was written.

Credential handling

Setup holds no secret, and connect.json holds no credential. The stream ticket is minted through the SDK's EventFeed().CreateStreamTicket and dropped at once. A failed read is reported by HTTP status only, so no server-written body reaches output, including a failed mint's. Setup's reads go through an SDK client with no request hooks, debug logger or cache, so -v cannot print a feed URL. Setup builds no feed connection and no poll source.

Merge order

No go.mod, vendored model or Nix hash change. The instance lock and ledger in Take the account event feed into a durable ledger are keyed on account and agent Person id. connect.json records both, with the account in the same canonical spelling.

Copilot AI balanced review requested due to automatic review settings September 16, 2026 20:23
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Trust-anchor path validation, operator persistence, credential preflight, and readiness reporting contain unresolved correctness and security issues.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds basecamp connect setup to create and validate connector policy for an authenticated agent profile.

Changes:

  • Adds credential, identity, operator, ticket, and route checks.
  • Introduces validated, atomic connect.json persistence.
  • Registers and documents the new command surface.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
skills/basecamp/SKILL.md Documents connector setup.
internal/connector/setup/private.go Implements private file I/O.
internal/connector/setup/private_unix.go Adds Unix security checks.
internal/connector/setup/private_unix_test.go Tests Unix file protections.
internal/connector/setup/private_other.go Adds non-Unix file handling.
internal/connector/setup/file.go Defines connect.json.
internal/connector/setup/file_test.go Tests schema and validation.
internal/connector/setup/checks.go Implements readiness checks.
internal/connector/setup/checks_test.go Tests readiness checks.
internal/connector/setup/apply.go Applies incremental configuration.
internal/connector/setup/apply_test.go Tests configuration changes.
internal/commands/doctor.go Shares check rendering.
internal/commands/connect.go Implements the new command.
internal/commands/connect_setup_test.go Tests setup workflows.
internal/commands/commands.go Adds command catalog entry.
internal/commands/commands_test.go Registers command in tests.
internal/cli/root.go Registers the root command.
e2e/smoke/smoke_lifecycle.bats Marks interactive setup out of scope.
.surface Records the CLI surface.
Review details

Suppressed comments (2)

internal/commands/connect.go:403

  • An explicit --deadline 0/0s is silently treated as absent, even though zero is below MinDeadline; this contradicts the pre-network validation applied to other invalid deadlines. Preserve whether the flag was supplied and reject zero.
	if f.deadline != 0 {

internal/commands/connect.go:460

  • This login branch runs before checking the existing policy's credential kind and pinned identity. If an Agent-backed connect.json has been logged out, rerunning with --expect-identity performs and stores a bot login, then fails the later kind check while claiming nothing changed; a bot policy can similarly consume/store a login for a different identity before the person mismatch is detected. Compare expect against existing.Agent.Kind and existing.Agent.IdentityID before launching either ceremony.
	case kind == "" && expect != 0:
  • Files reviewed: 19/19 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/commands/connect.go Outdated
Comment thread internal/connector/setup/private.go Outdated
Comment thread internal/connector/setup/private_other.go Outdated
Comment thread internal/commands/connect.go Outdated
Comment thread internal/commands/connect.go Outdated
Comment thread internal/connector/setup/checks.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 20:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Trust verification, non-Unix privacy enforcement, strict parsing, and result reporting have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 19/19 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread internal/connector/setup/private_other.go Outdated
Comment thread internal/commands/connect.go Outdated
Comment thread internal/commands/connect.go
Comment thread internal/connector/setup/file.go Outdated
Comment thread internal/commands/connect.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 20:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Machine output, strict parsing, terminal sanitization, and documented error-code behavior have unresolved correctness issues.

Review details

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/connect.go:359

  • path includes the environment-controlled config directory, so it can contain newlines or terminal control sequences. This direct one-line styled write bypasses the sanitization used by the error renderer and lets such a path break or restyle terminal output; quote or sanitize it before rendering.
    internal/commands/connect.go:372
  • The advertised contract says every failed token, identity, operator, scope, ticket, or route check exits with code not_ready, but this code is limited to post-write failures. Token failures return auth_required at lines 272-273, identity failures return auth errors at 280-286/630-654, and operator failures return usage at 322-323, so automation receives different codes for the documented checks. Preserve the pre-write behavior, but normalize these check failures to not_ready (or narrow the documented contract).

internal/connector/setup/file.go:248

  • Decoder.More is only a container-element check; after the top-level decode it returns false when the next non-whitespace byte is ] or }. Consequently, a valid policy followed by an unmatched closing delimiter is accepted here even though admission.ParsePolicy/json.Unmarshal rejects the same file, breaking the promised parser round-trip. Check that only whitespace remains after InputOffset (or decode once more and require EOF).
	if dec.More() {
		return File{}, errors.New("parse connect.json: trailing data after the object")
	}

internal/commands/connect.go:352

  • This result is constructed only after setup.Save succeeds, but Written is never set, so every machine-readable success reports "written": false for a file that was just written. Set it to true here (and assert it in the JSON-output test).
		Checks:        summarizeChecks(asDoctorChecks(checks)),
  • Files reviewed: 19/19 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings September 16, 2026 20:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Credential scope can be misreported and ticket error handling can expose server-returned bearer data.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

internal/commands/connect.go:282

  • This scope check reads profile configuration rather than the stored credential that just produced the token. Those values can diverge—for example, reconnecting an existing profile can store an approval downgraded to read without updating ProfileConfig.Scope—so setup can report a read-only connector as ready (or reject a full credential because of stale config). Load the credential's effective Scope from the credential store after AccessToken and base readiness on that value.
    internal/commands/connect.go:350
  • path comes from the configurable config-directory path and is written directly to a one-line terminal sink. Newlines or terminal escapes in that path can inject or restyle output. Sanitize it with richtext.SanitizeSingleLine before rendering, as the new route-check output already does.
    internal/commands/connect.go:673
  • This generated copy-paste command embeds a config-derived profile name without shell quoting. Profile keys loaded from configuration are not constrained here, so characters such as spaces or shell metacharacters can change the command when pasted. Use shellQuote(profile), following the established convention in internal/commands/templates.go:295-300 and internal/commands/files.go:1908-1918.
  • Files reviewed: 20/20 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/setup/checks.go
Copilot AI review requested due to automatic review settings September 16, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Credential scope can be misclassified, failed setup can retain a mismatched credential, and one styled path output is unsanitized.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/commands/connect.go:350

  • path comes from XDG_CONFIG_HOME/the home directory and may contain newlines or terminal escape bytes. Writing it directly to this styled terminal sink allows output injection; quote or sanitize it before rendering.

internal/commands/connect.go:283

  • The readiness check uses profile configuration rather than the credential that is actually being exercised. Those scopes can diverge: an agent approval/mint can narrow the stored credential to read, while an existing profile entry is not updated, so a read-only connector can be reported ready (and a full credential can be rejected because of stale config). Re-read Credentials.Scope after AccessToken and base this check on that value.
	if scope := profileScope(app, name); scope == "read" {
		checks = append(checks, setup.Check{Name: "Scope", Status: setup.StatusFail,
			Message: "The credential is read-only: the agent could not reply or acknowledge",
			Hint:    "Reconnect with full access."})
	}
  • Files reviewed: 20/20 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/commands/connect.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 21:11
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the two previously-missed comments in Copilot's reviews of 2c8f2ae and 79005d6, both fixed in 689a436:

  • connect.go styled path output: every printed path now goes through richtext.SanitizeSingleLine, both in the styled line and in the not_ready error. TestConnectSetupSanitizesThePathItPrints uses a config directory whose name contains a newline, and is red on 79005d6.
  • Scope from profile config: readiness now comes from setup.ScopeCheck on the stored credential's granted scope, never from the profile's configuration. Only full passes; a Launchpad grant, which carries no scope, counts as full. read, a missing scope and unknown values fail: TestScopeCheck is table-driven. TestConnectSetupReadsTheGrantedScopeNotTheProfiles uses a profile configured full with a read grant, and is red on 79005d6.
  • Unquoted profile name (connect.go:673): suggested commands now shell-quote profile names. TestConnectSetupQuotesProfileNamesInSuggestedCommands is red on 79005d6.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Raw SDK usage violates repository policy, and transaction failure paths can leave or overwrite configuration state.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

internal/commands/connect.go:325

  • The child can modify config.json before storing the credential: agent connect's BeforeStore commits the profile (internal/commands/auth_agent.go:249-280), then the credential write happens afterward (internal/auth/agent.go:648-654). If that write fails, this probe finds no credential, leaves tx.connected false, and the outer function skips rollback, so a failed setup leaves the profile registration/binding behind. Track the config mutation independently and restore it on this failure path too.
	if held == "" && connectCredentialStored(cmd.Context(), app) {
		tx.connected = true
	}
  • Files reviewed: 20/20 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread internal/commands/connect.go Outdated
Comment thread internal/connector/setup/checks.go Outdated
Comment thread internal/commands/connect.go Outdated
…nnect.json

Setup runs the agent connection (or the bot-user device login pinned by
--expect-identity) by reusing those commands whole, resolves the operator's
Person id (from their own profile, an id, or the default profile), writes
connect.json owner-only and atomically in admission's own types, and checks
token, identity, stream-ticket mint and each routed project's reads as the
agent — naming bc3's refusal of those reads to an Agent identity.
…ot ready

Review round 1 (Copilot and a separate Opus review):

- The operator is verified before connect.json is written, and an operator
  that fails (a client, an Agent, or an id the agent cannot read) is refused
  with nothing written. There is no default-profile operator any more: a
  first setup names one.
- The path to connect.json is checked from / down: the config directory,
  connect/ and the profile directory are this user's alone and no symlinks,
  and every ancestor (as written and as resolved) belongs to root or this
  user and is not writable by others unless sticky. On platforms without
  Unix modes, connect.json must be under the user's profile directory.
- A setup lock beside connect.json serialises load, change and save.
- A failed check exits non-zero with code not_ready; a read-only credential
  and a connector with no route are failures, not warnings.
- Operator-profile problems, an --expect-identity conflict and a bot login
  for a missing profile are refused before any browser opens; an unreadable
  credential store is refused rather than read as no credential.
- A typed --concurrency 0 or --deadline 0 is refused; --remove-route is
  idempotent; account ids and trust mode must be canonical; route paths are
  sanitised for the terminal.
…efused path

An operator already in connect.json was verified when it was recorded, so a
re-run that cannot re-read it as the agent (bc3 refuses Agent identities
that read) warns and keeps it; one that reads back as an Agent or a client
still fails, and a new id is still verified as a new trust anchor. The
ancestor refusals name the remedy, and the Windows open comment no longer
claims a swap check it cannot make.
Review round 2, fixed as classes:

- Trust: setup.VerifyTrust is the single check every setup runs before
  connect.json is written. It documents and checks each property of the
  trust anchor, for the operator and every allowlisted id alike: a Person
  id, never the agent, verified in the account (by the operator's own
  credential, or a read, which goes through that credential when there is
  one), not an Agent, not a client, and answered for the id asked. Only a
  person connect.json already records in the same role may be unreadable,
  as a warning.
- Reporting: setup.Report derives ready from its checks on every call and
  in its JSON; it cannot be ready with no checks, with nothing written, or
  with any check whose status is not pass, warn or skip. written is set.
- Parsing: duplicate keys at any depth and any trailing data are refused.
- Off Unix, where ownership and ACLs are not verified, connect.json is
  neither read nor written; setup says why instead of trusting the path.
- The first help example names the operator.
encoding/json folds field-name case and parses project ids as numbers, so
a duplicate check on exact keys missed "Trust" beside "trust" and
"048699913" beside "48699913". Every key must now be in its one
canonical spelling. The recorded-trust comment says what it assumes, and
the tests that need Unix modes build only there.
Review rounds 3 and 4:

- Transactional. Every check runs before anything is kept, and
  connect.json is written only when all of them pass. When the run's own
  connection or login stored a credential and the run then fails, at any
  step, the credential is removed and the global config file is restored
  byte for byte, and the error says to disconnect the agent in Basecamp.
- Scope. setup.ScopeCheck is the one classification: only "full" is
  sufficient (a Launchpad grant, which carries none, is full); read, a
  missing scope and any value this CLI does not know are insufficient. It
  reads the scope stored from the server's answer, never the profile's
  configuration.
- Read errors. setup.ErrorText is the one formatter: an HTTP answer is
  reduced to its status, so no server-written body (a failed ticket mint's
  included) reaches the checks, the error or the output.
- Paths printed to the terminal are sanitised; suggested commands
  shell-quote profile names.
…ough the SDK

Review round 5 (Copilot and the Opus review):

- The ticket mint goes through the SDK's EventFeed().CreateStreamTicket,
  now on main, instead of a raw account-client POST.
- The transaction's invariants are written on setupTransaction and each has
  a test that fails without it:
  1. it undoes only this run's credential step: the credential it stored,
     the profile entry it registered, the account it bound, never the
     whole config file, so concurrent changes survive;
  2. only while that credential is unproven (token, identity, kind, person,
     account, scope): a proven credential is kept when trust, the ticket, a
     route or the write fails, so a rerun needs no second approval;
  3. a config change is undone even when the credential write itself
     failed after the connection committed the entry;
  4. connect.json stays one atomic rename after every check passes.
- The undo note names the flow that ran: disconnecting in Basecamp only for
  an agent connection.
- The token and identity-endpoint errors go through setup.ErrorText too.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Lock failure classification and lock-proof enforcement do not yet uphold the documented concurrency and security guarantees.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 23/23 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread internal/connector/setup/file.go Outdated
Comment thread internal/connector/setup/private.go Outdated
Comment thread internal/commands/connect.go Outdated
Comment thread internal/commands/connect.go
Review round 12:

- auth.HeldCredential is now an interface with a method only the auth
  package can implement, so no other package can fabricate the proof, and
  WithCredential invalidates the value when the lock is released:
  Credentials and Key answer empty afterwards. setup.Save refuses anything
  but a live one, and it is the only writer of connect.json outside the
  package's own file-level tests.
- Every acquisition outcome is classified in lockFile and nowhere else:
  held returns the release, held-by-another returns the retryable
  rate-limit refusal, and a lock that cannot be created at all either warns
  and proceeds (an ordinary credential operation) or refuses with
  lock_unavailable (a caller whose guarantee is the lock). There is no path
  that returns neither a lock nor an error.
- The lock-unavailable hint says other commands still work and names
  connector setup as what needs the lock.
Copilot AI review requested due to automatic review settings September 16, 2026 23:08
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On lock classification and the lock proof (Copilot's review of f6ac37f): both finished structurally in 9660119.

Classification. Every acquisition outcome is decided in lockFile and nowhere else:

  • held: the release is returned;
  • held by another: the wait expires into a retryable rate_limit refusal, and nothing is changed;
  • cannot be created at all: an ordinary credential operation warns once and proceeds, as it always has, while a caller that asked for the lock itself (require, which only WithCredential sets) is refused with lock_unavailable. That branch returns an error, so there is no path that returns neither a lock nor an error.

Proof. auth.HeldCredential is now an interface with an unexported method, so no other package can implement or fabricate it, and WithCredential invalidates the value when the lock is released (Credentials and Key answer empty from then on). setup.Save takes one and refuses anything that is not live.

Writers. grep -rn 'setup.Save(\|writePrivate(' internal --include=*.go outside tests gives exactly two lines: the one call in connect.go, and writePrivate inside save, which only Save calls. No write path without the proof remains.

Tests: TestHeldCredentialIsInvalidAfterTheLockIsReleased, TestSaveRefusesWithoutAHeldCredential, TestWithCredentialRefusesWhenTheHostCannotLock, TestConnectSetupRefusesWhenTheHostCannotLock.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Lock failure classification, retry metadata, and cancellation propagation need correction.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/connect.go:345

  • These helpers turn every request error into a failed check. If the command context is canceled during the ticket mint or a project read, setup therefore returns not_ready and tells the user to fix readiness instead of propagating cancellation. Check ctx.Err() after each helper and return it before classifying the report.
    internal/commands/connect.go:428
  • busy is transient, but this error leaves Retryable false. JSON consumers use that field—not the exit code or hint—to decide whether to retry (internal/output/output_test.go:473-476), so they will treat setup-lock contention as a permanent verdict. Set Retryable: true for this error.

internal/connector/setup/private.go:66

  • If the filesystem rejects flock (for example, an unsupported network/FUSE mount), this returns a plain error. runConnectSetup treats every non-ErrSetupRunning error from Lock as usage, so this path never produces the promised lock_unavailable code. Wrap acquisition failures in a distinguishable sentinel/typed error and map that to CodeLockUnavailable.
	held, err := lock.TryLock()
	if err != nil {
		return nil, fmt.Errorf("take the setup lock: %w", err)
  • Files reviewed: 23/23 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

An unexported method cannot be declared outside its package, but it can be
promoted by embedding the interface, and such a wrapper can answer whatever
it likes. auth.Held now reads a proof by asking the auth package: anything
it did not make is not held, whatever its methods say, and setup.Save uses
only that. The doc says what the proof does and does not promise: the lock
was held when Save checked it, and the write's own atomicity does the rest.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Setup-lock acquisition failures are incorrectly classified as usage rather than lock_unavailable.

Review details

Suppressed comments (1)

internal/commands/connect.go:192

  • A failure to create or flock the per-profile setup lock reaches this branch too, so an unsupported/read-only locking filesystem is currently reported as usage. That contradicts the command's lock_unavailable contract and prevents callers from distinguishing bad input from a host that cannot provide the required synchronization. Have setup.Lock wrap acquisition failures in a dedicated sentinel and map that sentinel to output.CodeLockUnavailable here, while retaining privacy/path failures as usage.
		return output.ErrUsage(err.Error())
  • Files reviewed: 23/23 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

The proof's interface now carries nothing but the method that makes it
auth's own, so a wrapper has nothing to answer for and no reader can be
fooled by one. auth.Held is the only reader, and setup's write and the
command's last credential check both go through it.
Copilot AI review requested due to automatic review settings September 16, 2026 23:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Lock classification, identity pinning, retry metadata, and credential-lock proof remain incomplete.

Review details

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/connect.go:324

  • On a rerun for a bot profile, an explicit --expect-identity replaces the identity pinned in the existing file before VerifyAgent runs. If the credential now reports that new identity while /my/profile still yields the same person ID, setup silently rebinds connect.json, despite the requirement that a rerun match the recorded agent identity. Compare expect with existing.Agent.IdentityID and require removing the old file for a deliberate rebind.
    internal/commands/connect.go:430
  • This transient contention error leaves Retryable false. Machine-output consumers explicitly key on retryable rather than the code (internal/output/envelope.go:59-65), so they will treat busy as a permanent verdict even though the hint says to retry after the other process finishes.

internal/connector/setup/file.go:380

  • The lock key returned by auth.Held is discarded, so a live hold for any credential (for example profile:other) authorizes writing this profile's policy. This defeats Save's documented type-level guarantee that the credential named by the file is the one locked. Require the held key to equal the credential key derived from f.Profile.
func Save(held auth.HeldCredential, path string, f File) error {
	if _, _, ok := auth.Held(held); !ok {
		return errors.New("connect.json is written only while the profile's credential is locked")
	}

internal/commands/connect.go:192

  • When the setup lock file cannot be created or flock is unsupported, setup.Lock returns a non-busy error and this branch converts it to usage. That violates the command contract for an unavailable setup lock (lock_unavailable, exit 5); the existing test only exercises the later credential lock. Give setup-lock acquisition failures a distinct sentinel/type and map that to CodeLockUnavailable, while keeping privacy/path validation errors as usage.
	if err != nil {
		if isLockBusy(err) {
			return errBusy(name, err)
		}
		return output.ErrUsage(err.Error())
  • Files reviewed: 23/23 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…locked profile

Review rounds 13 and 14 (Copilot):

- setup.Lock returns ErrLockUnavailable for a host that cannot lock — a
  filesystem without flock, a lock file it cannot even inspect — and the
  command maps it to lock_unavailable. A lock file that is not this user's
  own regular file is refused before flock sees it, so two setups cannot
  lock different inodes.
- Contention is busy and retryable wherever it comes from, including the
  credential store's own rate-limited wait, since machine consumers read
  the retryable field.
- setup.Save requires the held lock to be the one for the file's own
  profile: a lock on another profile's credential is not a lock on this.
- A rerun does not rebind connect.json to another identity because
  --expect-identity says so; that needs the file removed.
- A cancelled run reports the interruption rather than a readiness verdict.
Copilot AI review requested due to automatic review settings September 16, 2026 23:53
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the suppressed comments in the last three reviews, all fixed in db18519:

  • Cancellation (connect.go:345): a cancelled run now returns the context's error rather than a readiness verdict. TestConnectSetupPropagatesCancellation cancels during the ticket mint; red without the check.
  • busy not retryable (connect.go:428/430): Retryable is set, and credential-store contention is mapped to busy too.
  • setup.Lock acquisition failures (private.go:66, connect.go:192): typed ErrLockUnavailable, mapped to lock_unavailable.
  • Identity rebinding on a rerun (connect.go:324): --expect-identity naming another identity than connect.json records is refused as auth, and says to remove the file for a deliberate rebind. TestConnectSetupRefusesToRebindToAnotherIdentity is red without it.
  • The held key was discarded (file.go:380): setup.Save now requires the held lock to be the one for the file's own profile, through auth.ProfileCredentialKey. TestSaveRefusesAHoldOnAnotherProfile holds profile:other and is refused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Required credential locks need hardened paths, and final-lock cancellation is currently misclassified as usage.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/commands/connect.go:411

  • Cancellation while waiting for or reading under the final credential lock is reclassified as a usage error here. WithCredential returns context.Canceled/context.DeadlineExceeded directly, but classifyWriteError falls through to ErrUsage, unlike the earlier cancellation check which preserves the context error. Preserve context cancellation before applying write-error classifications so Ctrl-C during lock contention remains an interruption rather than invalid input.
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/auth/lock.go
…ption

Review round 13 (Copilot):

- A required credential lock now refuses a lock directory or lock file this
  user does not own: a symlink, another user's file, or one others can
  write. A lock nobody can be sure names the same inode is not a lock, so
  it reports lock_unavailable rather than being used. Ordinary locks are
  unchanged.
- Cancellation under the final lock stays an interruption: a context that
  ended is returned as it is, not classified as invalid input.
- ProfileCredentialKey has its own doc, and the busy arm its own return.
Copilot AI review requested due to automatic review settings September 17, 2026 00:04
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the suppressed comment (connect.go:411, cancellation under the final lock classified as usage): fixed in b526e73. classifyWriteError returns a context.Canceled or context.DeadlineExceeded as it is, before any other classification, so Ctrl-C while waiting for the credential lock stays an interruption and nothing is written. TestConnectSetupClassifiesCancellationUnderTheLock covers both, red on db18519.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The implementation and extensive tests consistently enforce the stated identity, readiness, confidentiality, and locking guarantees.

Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations output Output formatting and presentation skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants