Documentation
¶
Overview ¶
Package setup is `basecamp connect setup`'s half of the connector (plan step 16): connect.json — its schema, where it lives, how it is written and how it is refused — and the checks that can run before the connector does.
connect.json ¶
connect.json is the only local authority for which Basecamp projects may drive the agent and for who may drive it. It is local policy: nothing read from Basecamp serves a project or widens trust. Its admission part (trust and projects) is exactly what admission.ParsePolicy reads; the rest belongs to dispatch.
No directory is associated with a project. The connector runs where it was started, and a task that needs a clone or a directory of its own is the agent's business to make.
Because the file is the trust anchor, a copy another user could have written is not one to act on. Save writes it owner-only (0600, in 0700 directories) and atomically; Load refuses a file or directory another user owns or can write, a symlink, and anything that is not a regular file. A policy the connector would misread is refused at both ends.
What is not here ¶
The agent's credential. Setup runs on a profile that already holds it (connected with `basecamp auth agent connect`, or a bot user logged in with `basecamp auth login --expect-identity`), and never stores, replaces or removes one. connect.json records only the identity that credential proved, so a later run can refuse a profile that has since been pointed at someone else.
Index ¶
- Constants
- Variables
- func CheckPrivateDir(dir string) error
- func CheckPrivateFile(path string) error
- func EnsurePrivateDir(dir string) error
- func EnsurePrivateFile(path string) error
- func ErrorText(err error) string
- func Lock(ctx context.Context, path string) (unlock func(), err error)
- func Path(configDir, profile string) (string, error)
- func ReadPrivateStateFile(path string) ([]byte, error)
- func Save(held auth.HeldCredential, path string, f File) error
- func SetOperator(t *admission.Trust, id int64, requested bool) error
- func TryLock(path string) (unlock func(), err error)
- func TryLockPrivate(path string) (unlock func() error, err error)
- func UsableTicket(t *basecamp.StreamTicket) bool
- func ValidClass(class string) bool
- func WritePrivateStateFile(path string, data []byte) error
- type AckBoost
- type Agent
- type Changes
- type Check
- func ProjectChecks(ctx context.Context, r Reader, f File, firstSetup bool) []Check
- func ScopeCheck(oauthType, granted string) Check
- func TicketCheck(ctx context.Context, r Reader, kind string) Check
- func VerifyPings(ctx context.Context, r Reader, pings, recorded map[int64]admission.PingTrust, ...) []Check
- func VerifyTrust(ctx context.Context, people Reader, t Trust, agentID int64) []Check
- type Duration
- type File
- type Person
- type Ping
- type PingPerson
- type Reader
- type Report
- type SDKReader
- func (r SDKReader) Me(ctx context.Context) (Person, error)
- func (r SDKReader) MintStreamTicket(ctx context.Context) error
- func (r SDKReader) Person(ctx context.Context, id int64) (Person, error)
- func (r SDKReader) Ping(ctx context.Context, id int64) (Ping, error)
- func (r SDKReader) Project(ctx context.Context, id int64) error
- func (r SDKReader) ProjectPeople(ctx context.Context, id int64) error
- type Trust
Constants ¶
const ( StatusPass = "pass" StatusFail = "fail" StatusWarn = "warn" StatusSkip = "skip" )
Check statuses, the doctor command's vocabulary.
const ( // KindAgent is an Agent person holding a client_credentials profile, the // connection ceremony's result. KindAgent = "agent" // KindBotUser is a person login (a device-flow bot user) pinned by // --expect-identity: version 1's path. KindBotUser = "bot_user" )
Credential kinds recorded for the agent.
const AckContentMax = 16
AckContentMax is the longest boost Basecamp takes, counted in runes as `basecamp boost create` counts it.
const DefaultAckContent = "👀"
DefaultAckContent is the boost the connector acknowledges with unless connect.json says otherwise.
const FileName = "connect.json"
FileName is connect.json's name inside its profile directory.
const MaxTicketLifetime = time.Hour
MaxTicketLifetime bounds a ticket's reported lifetime. The server issues about two minutes; a lifetime past an hour is not one the contract allows.
const PersonableAgent = "Agent"
PersonableAgent is the personable type Basecamp gives an Agent person.
const Version = 1
Version is the connect.json schema version this package writes and reads.
Variables ¶
var ErrLockHeld = errors.New("the lock is held by another process")
ErrLockHeld reports a lock another process already holds.
ErrLockUnavailable reports a host that cannot take the setup lock at all: a filesystem without flock, a lock file this user does not own. Setup's guarantee is the lock, so it refuses rather than run without one.
var ErrMalformedTicket = errors.New("the stream ticket response carries no ticket, no URL, or no lifetime within bounds")
ErrMalformedTicket reports a mint that answered without a ticket or URL.
var ErrNotPrivate = errors.New("connect.json is not private to this user")
ErrNotPrivate reports a connect.json, or a directory holding it, that someone other than this user could have written.
var ErrSetupRunning = errors.New("another command holds this profile's connector policy")
ErrSetupRunning reports the per-profile policy lock held by somebody else. Three callers take it, and which one is holding decides whether a wait is worth anything: `connect setup` across its whole load-change-save, network checks included; `connect redispatch`, a one-shot operator command, across one read and one ledger write; and the running connector, for the moment it authorizes a launch. See the block above LockWait.
var LockWait = 10 * time.Second
Who takes this lock, and for how long. Two holders, and a third form for the one caller that must never wait:
- `connect setup` (Lock) holds it across its whole load, change and save, network checks included. Seconds, and on a slow or unreachable account longer than that.
- `connect redispatch` (Lock) holds it across one file read and one ledger transaction, then lets go before it re-runs a prerequisite against Basecamp. Milliseconds. It is a one-shot operator command, not the connector.
- the running connector (TryLock) takes it once per launch, across one file read and one ledger transaction, and never waits: a pass that cannot take it dispatches nothing and asks again on its next tick.
Setup is the only one that holds it across a network call. Other locks are taken UNDER this one and never the other way about: the ledger's SQLite locks on a redispatch and on a launch, and the credential key's lock on setup's final write. That one order is what keeps this from deadlocking, and a fourth caller that waits on this while holding something a current holder waits on is how it stops being one. There is no such caller today.
LockWait bounds Lock's wait for a holder to finish. The connector takes this lock too now, and what it does under it is one file read and one SQLite transaction — bounded by the ledger's own five-second busy retry — so a setup that refused the instant a dispatcher pass overlapped it would fail for no reason a person could act on. Ten seconds is that worst case with room, and a setup that waits it out reports a real holder.
A variable so tests can shorten it.
Functions ¶
func CheckPrivateDir ¶
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 CheckPrivateFile ¶
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 EnsurePrivateDir ¶
EnsurePrivateDir creates dir owner-only when it is missing, and refuses it when dir — or any directory above it — could be changed by someone else.
It is the same check connect.json gets, exported for the state the running connector keeps beside it: the instance lock and the ledger live under the same config root, and a directory another local user can write is a directory where a lock file can be swapped for one that excludes nobody.
Only the last component is created. Everything above it must already exist and already be trustworthy, which is what makes the refusal meaningful.
func EnsurePrivateFile ¶
EnsurePrivateFile creates path owner-only when it is missing, and refuses it when it is not this user's own regular file reached through directories only this user can change.
It is stricter than the check connect.json gets in one way: a file another user can merely READ is refused too. It exists for the connector's ledger, which holds the account's feed positions — resumable tokens, so credentials — and there a readable file is already the leak.
What is validated is the descriptor, not the name: the file is opened without following symlinks and inspected through that open file, so the thing checked is the thing the caller will go on to use. Creation is exclusive for the same reason — O_CREAT|O_EXCL refuses a symlink outright rather than following it somewhere else.
func ErrorText ¶
ErrorText is the one way setup turns a read's error into text, for every check and every refusal. An HTTP answer is reduced to its status: the SDK builds an error's message from the response body, which the server controls, and a ticket mint's body can carry the ticket and the feed URL. Only an error that never reached a server keeps its text, reduced to one line.
func Lock ¶
Lock takes the per-profile setup lock beside connect.json, so a load, a change and a save cannot interleave with another holder's. It waits up to LockWait, or until ctx ends, and then reports ErrSetupRunning or ctx's own error.
The wait honors ctx, and a wait that ends takes nothing: a person who pressed Ctrl-C during contention is not made to sit out the bound, and a command whose context has expired does not go on to acquire a lock and act under it (Copilot on #771). What a caller that cannot get the lock gets is a refusal, never a fall-through: "the policy could not be checked" is not permission to change it.
The wait is for the connector's hold, which is milliseconds. It cannot tell one holder from another, though, so a second `connect setup` whose predecessor finishes inside LockWait is now queued rather than refused — two setups run one after the other where they used to be one setup and one refusal. That is a real change, and the price of not failing a setup that happens to overlap a dispatcher pass. Nothing is written twice by it: each one loads, changes and saves under the lock in turn, which is what the lock is for. A second setup whose predecessor is still going after LockWait is still refused.
func Path ¶
Path is where a profile's connect.json lives: under the CLI's config directory, one directory per profile, since a connector runs as one profile's agent.
func ReadPrivateStateFile ¶ added in v0.17.0
ReadPrivateStateFile reads a small file the connector keeps in its state, held to CheckPrivateFile's rules through the descriptor it reads: the directories on the way this user's alone, the file not a symlink, this user's own, and readable by nobody else. A missing file is an error satisfying errors.Is(err, os.ErrNotExist).
func Save ¶
func Save(held auth.HeldCredential, path string, f File) error
Save validates f and writes it to path atomically, owner-only, while the caller holds the lock on the credential the file is about to name. The proof is auth.HeldCredential, and auth.Held is what reads it: a value this package could fabricate — a struct embedding the interface — is not one auth made, and is refused.
The proof must name this file's own profile: a lock held on another profile's credential is not a lock on this one. The proof says the lock was held when Save checked it. What happens after that is the write's own: one atomic rename, so a connect.json exists whole or not at all.
held is only proof; what is written comes from f, which the caller has already checked against the credential (see File.VerifyAgent).
func SetOperator ¶ added in v0.13.0
SetOperator records the operator. When the allowlist then names nobody besides them, the assignment opt-in has nobody left to cover. Kept from an earlier run, it is dropped: the operator assigns without one, and keeping it would make the file refuse to validate over a change setup itself made. Asked for in this run (requested), it is refused, so the person learns it does nothing rather than finding it quietly off.
func TryLock ¶
TryLock takes the lock if it is free and reports ErrSetupRunning if it is not, without waiting. It is the dispatcher's form: a pass that cannot take the lock authorizes nothing and tries again on its next tick, which costs one tick of latency. Waiting instead would park the dispatcher behind a `connect setup`'s network checks, and a dispatcher that blocks on a file another process writes is its own hazard. It takes no context because it does not wait: there is nothing to cancel.
func TryLockPrivate ¶
TryLockPrivate takes an exclusive lock on path without waiting, having first established that the lock is a lock: the directory holding it is this user's alone, and the file itself is this user's own regular file rather than a symlink or something another user left there. A lock on a file two processes can be pointed at different inodes excludes nobody.
It reports ErrLockHeld when another process holds it, which is an answer rather than a failure. Every other error means the lock could not be established, and the caller must refuse rather than carry on unlocked.
func UsableTicket ¶
func UsableTicket(t *basecamp.StreamTicket) bool
UsableTicket reports whether a minted ticket is one the connector could dial: a credential, a URL, and a lifetime above zero and at most MaxTicketLifetime.
func ValidClass ¶
ValidClass reports whether a class is one connect.json can carry: it is written onto every stdout pointer line, so it stays a short plain token.
func WritePrivateStateFile ¶ added in v0.17.0
WritePrivateStateFile replaces a small file the connector keeps in its state, atomically and owner-only (0600), in a directory EnsurePrivateDir vets. A file already there that is not this user's own regular file is refused rather than replaced, as connect.json's is.
Types ¶
type AckBoost ¶ added in v0.14.0
AckBoost is the connector's acknowledgement of a request: a boost on the request's recording with Content, given before the request is handed off, or none at all when Off, which leaves acknowledging to the session.
type Agent ¶
type Agent struct {
// PersonID is the agent's Person id in AccountID. Admission takes the
// agent's id from the profile's verified identity at run time; this is
// what that identity is checked against.
PersonID int64 `json:"person_id"`
// Kind is KindAgent or KindBotUser.
Kind string `json:"kind"`
// IdentityID is the bot user's account-independent identity, the value
// --expect-identity pins. Zero for an Agent person, which has none.
IdentityID int64 `json:"identity_id,omitempty"`
}
Agent is the identity the agent profile authenticated as at setup.
type Changes ¶
type Changes struct {
// Trust is the trust mode, "" to keep the file's.
Trust admission.TrustMode
// Allow replaces the allowlist when non-empty: the people trusted as
// operators besides the operator. It implies allowlist mode unless the
// same run asks for project mode, which admits the project's members as
// participants beside them.
Allow []int64
// AllowAssignments turns the allowlist's assignment opt-in on or off.
// Nil keeps the file's, except in a run that passes Allow, which
// restates the opt-in with the list it covers: off unless set here.
// SetOperator drops a kept one that is left covering nobody.
AllowAssignments *bool
// AllowAgents adds agents allowed to wake this one by @mentioning it;
// DisallowAgents removes them. The list is kept across runs, like the
// served projects.
AllowAgents []int64
DisallowAgents []int64
// AgentThreadCap and AgentDailyCap set the agent loop caps; nil keeps
// the file's. Zero restores the default.
AgentThreadCap *int
AgentDailyCap *int
// AllowInPing lets people reach the agent in one Ping alone, as
// participants there; DisallowInPing takes them out. Kept across runs,
// like the allowed agents.
AllowInPing []PingPerson
DisallowInPing []PingPerson
// Serve are the project (bucket) ids to serve. A project already served
// keeps its class and watch_completions.
Serve []int64
// Classes sets a served project's class; an empty class clears it.
Classes map[int64]string
// WatchCompletions turns watch_completions on (true) or off (false) for
// a served project.
WatchCompletions map[int64]bool
// Remove stops serving projects.
Remove []int64
// AckBoost sets how the connector acknowledges a request; nil keeps the
// file's.
AckBoost *AckBoost
}
Changes is what one setup run asks to change. A zero field leaves the file's value alone, so setup can be run again to serve another project without restating everything else.
type Check ¶
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
Check is one setup check's result.
func ProjectChecks ¶
ProjectChecks reads each served project the way admission will, as the agent: the project, and its people (project trust mode's membership read).
firstSetup says there is no connect.json yet. Serving no project is two different questions depending on it, and answering both with one rule left the operator no way to turn the agent off through the interface that turned it on: an explicit --unserve of the last project produced a valid empty file that the readiness check then refused to write (Copilot on #765). A first setup that serves nothing has not been set up, and is refused. Withdrawing the last project is a thing an operator may mean — it is how you stop the agent working anywhere without editing the trust anchor by hand — so it is written, and warned about.
func ScopeCheck ¶
ScopeCheck classifies the scope a credential was granted, as stored from the server's answer. Only "full" lets the agent reply and acknowledge. Launchpad grants carry no scope and are full access. Anything else — read, empty on a credential that should carry one, or a value this CLI does not know — is insufficient, never assumed full.
func TicketCheck ¶
TicketCheck mints a stream ticket as the agent.
func VerifyPings ¶ added in v0.14.0
func VerifyPings(ctx context.Context, r Reader, pings, recorded map[int64]admission.PingTrust, agentID int64) []Check
VerifyPings checks everyone connect.json will let into one Ping, as the agent sees that Ping, since admission reads it as the agent: the agent is in it, so is the person, and the person is neither the agent, an Agent, nor a client of the account. One read per Ping.
A person recorded there already who cannot be confirmed now (the Ping cannot be read, or they have left it) is a warning, as for the allowlist: a later setup run should not fail over an entry it was not asked to change. An Agent or a client fails whether recorded or not: no agent authorizes, and clients are what project trust refuses too.
func VerifyTrust ¶
VerifyTrust is the one check of the trust anchor every setup goes through before connect.json is written. A failed result means nothing may be written. The properties, each checked here:
- The operator and every allowlisted id are Person ids (positive).
- None of them is the agent itself: the agent's own id never authorizes.
- Each was verified as a person in the agent's account: the operator by their own profile's credential, or else by reading them; every allowlisted id by reading it. A read that is refused or fails refuses the id, unless connect.json already records that same person in that same role, which was verified when it was written.
- None of them reads back as an Agent person: agents never authorize.
- None of them reads back as a client of the account.
- A read answers for the id that was asked for.
people reads persons in the account; pass a reader built on the operator's own credential when there is one, since Basecamp refuses person reads to an Agent identity today.
type Duration ¶
Duration is a time.Duration written as Go's duration string ("45m0s").
func (Duration) MarshalJSON ¶
MarshalJSON implements json.Marshaler.
func (*Duration) UnmarshalJSON ¶
UnmarshalJSON implements json.Unmarshaler.
type File ¶
type File struct {
Version int `json:"version"`
// Profile is the CLI profile holding the agent's credential.
Profile string `json:"profile"`
// AccountID is the account the agent belongs to.
AccountID string `json:"account_id"`
// Agent is who the profile's credential proved to be when setup ran.
Agent Agent `json:"agent"`
// Trust and Projects are admission's, in admission's own types, so the
// two cannot drift. Projects is the list of Basecamp projects this agent
// serves, keyed by project id.
Trust admission.Trust `json:"trust"`
Projects map[int64]admission.Project `json:"projects"`
// Pings lets people reach the agent in one Ping alone, as participants
// there (--allow-in-ping), keyed by the Ping's id. Absent when it lets
// nobody in, so a file that uses none still opens in a CLI that predates
// the key.
Pings map[int64]admission.PingTrust `json:"pings,omitempty"`
// AckBoost is how the connector acknowledges a request someone made of
// the agent (--ack-boost, --no-ack-boost). Absent means the default, a
// DefaultAckContent boost, and setup writes it only when it is not, so a
// file that keeps the default still opens in a CLI that predates the
// key.
AckBoost *AckBoost `json:"ack_boost,omitempty"`
// The Legacy fields are the settings of a connector that ran its own
// workers: which driver and coding agent, how many at once, for how
// long, and dangerous mode. The connector no longer starts workers
// (the person's own session does the work), so nothing reads these.
// They are fields for the same reason LegacyWorktrees is: Parse refuses
// an unknown key, and a connect.json written before has them. Parse
// zeroes them, and omitempty keeps them out of anything written from
// here.
LegacyDriver string `json:"driver,omitempty"`
LegacyWorker string `json:"worker,omitempty"`
LegacyConcurrency int `json:"concurrency,omitempty"`
LegacyDeadline string `json:"deadline,omitempty"`
LegacyDangerous bool `json:"dangerous,omitempty"`
// LegacyWorktrees is the --worktrees setting of a connector that gave
// each task a git worktree of its own. Worktrees are gone: a task runs
// where the connector was started, and nothing here reads this. It is
// still a field
// because Parse refuses an unknown key, and a connect.json written
// before they went has "worktrees" in it — accepting it is what lets
// that file still open. Parse zeroes it, and omitempty keeps it out of
// everything written from here, so the next `connect setup` writes the
// key away for good.
LegacyWorktrees bool `json:"worktrees,omitempty"`
}
File is connect.json.
func Apply ¶
Apply returns f with ch applied. f is not modified. Everything that can be refused without the network is refused here; the result still has to pass Validate, which Save runs.
func Load ¶
Load reads and validates the connect.json at path. A file that does not exist is reported as os.ErrNotExist (use errors.Is). A file this user does not solely control is refused before a byte of it is believed.
func Parse ¶
Parse decodes connect.json strictly. It refuses what encoding/json would quietly accept: an unknown key (a misspelled "watch_completion" ignored is a project the operator believes is driven and is not), a key given twice (only the last would count, so the file would not say what it reads as), and anything after the object.
func (File) Ack ¶ added in v0.14.0
Ack is the acknowledgement this file asks for, the default when it names none.
func (File) Policy ¶
Policy is the admission policy this file yields for the agent, obtained the way the connector obtains it: by serializing the file and parsing it with admission.ParsePolicy, then validating. A file whose policy does not validate is one admission would refuse, so setup refuses to write it.
func (File) Validate ¶
Validate refuses a file the connector could misread. It covers everything Policy does and the dispatch settings besides.
func (File) VerifyAgent ¶
VerifyAgent refuses a connect.json that does not describe the identity a profile's credential authenticates as. It is the check that makes a credential replaced behind setup's back harmless: whoever acts on connect.json — setup before it writes, the connector before it runs — proves the file and the credential name one agent, and refuses otherwise.
type Ping ¶ added in v0.14.0
Ping is the part of a Basecamp Ping the checks look at. People are the others in it, as the reader sees it.
type PingPerson ¶ added in v0.14.0
PingPerson is a person in one Ping.
type Reader ¶
type Reader interface {
// Me is the person the credential authenticates as.
Me(ctx context.Context) (Person, error)
// Person reads someone else in the account.
Person(ctx context.Context, id int64) (Person, error)
// MintStreamTicket mints a stream ticket and throws it away. The ticket
// is a bearer credential: an implementation must neither return, log nor
// keep it.
MintStreamTicket(ctx context.Context) error
// Project reads a project.
Project(ctx context.Context, id int64) error
// ProjectPeople reads a project's people.
ProjectPeople(ctx context.Context, id int64) error
// Ping reads a Ping the reader is in.
Ping(ctx context.Context, id int64) (Ping, error)
}
Reader is what the checks read, as the agent, in the agent's account.
type Report ¶
type Report struct {
Path string
Profile string
AccountID string
AgentPersonID int64
AgentKind string
OperatorID int64
TrustMode string
// Projects is how many Basecamp projects the file serves.
Projects int
Written bool
// contains filtered or unexported fields
}
Report is the outcome of one setup run: what was written and every check. Whether the connector is ready is never stored: it is derived from the checks every time it is asked for, so no code path can report ready while a check has failed or before any check ran.
func (*Report) MarshalJSON ¶
MarshalJSON writes the report with ready derived, never stored.
type SDKReader ¶
type SDKReader struct {
Client *basecamp.AccountClient
}
SDKReader is Reader over an SDK account client. Build the client without request hooks or a debug logger: they log request URLs, and a feed URL can carry a position or a ticket.
func (SDKReader) MintStreamTicket ¶
MintStreamTicket implements Reader through the SDK's stream-ticket operation, and accepts exactly what the connector runtime's minter accepts (eventfeed's liveMinter refuses a ticket with no credential, no URL or no positive lifetime), with the lifetime also bounded above. The ticket is dropped on return.
type Trust ¶
type Trust struct {
// Operator is the operator. When OperatorProfile is set, it is the person
// that profile's own credential read back, which proves who they are.
Operator Person
OperatorProfile string
// OperatorIsOwner says Operator is the person the agent works for, as
// Basecamp named them in the agent's own profile. Basecamp's word proves
// who they are, as a profile's own credential does, so they are not read
// again: a new agent may share no project with them yet.
OperatorIsOwner bool
// Allowlist is every allowlisted Person id the file will hold.
Allowlist []int64
// Agents is every agent the file will allow to mention this one. Each
// must read back as an Agent person: the reverse of the allowlist.
Agents []int64
// Recorded is the trust connect.json already holds for this same agent
// and account. It is taken as the owner's own record — the file is
// private to them, and setup verified it if setup wrote it — so a person
// in it who cannot be re-read now is a warning, not a refusal. A read
// that answers still decides. The zero value records nobody.
Recorded admission.Trust
}
Trust is who connect.json says may drive the agent, as setup is about to write it, and how each person in it came to be named.