Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .surface-skill-drift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ FLAG basecamp --operator-profile
FLAG basecamp --serve
FLAG basecamp --shadow
CMD basecamp connect discard
CMD basecamp connect ask
CMD basecamp connect doctor
CMD basecamp connect import
CMD basecamp connect release
Expand Down
3 changes: 2 additions & 1 deletion internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ release. Linux and macOS only.
basecamp connect status what it heard and holds
basecamp connect doctor what it needs to run
basecamp connect discard <id> close a record without running it
basecamp connect ask <url> declare the agent's post an ask for a go
basecamp connect release clear the hold
basecamp connect shadow promote make the shadow ledger the connector's, held
basecamp connect import <file> apply a cutover reconciliation file`,
Expand All @@ -81,7 +82,7 @@ release. Linux and macOS only.
}
addConnectRunFlags(cmd, &run)
cmd.AddCommand(newConnectSetupCmd(), newConnectShowCmd(), newConnectStatusCmd(), newConnectDoctorCmd(),
newConnectDiscardCmd(), newConnectReleaseCmd(), newConnectShadowCmd(), newConnectImportCmd())
newConnectDiscardCmd(), newConnectAskCmd(), newConnectReleaseCmd(), newConnectShadowCmd(), newConnectImportCmd())
return cmd
}

Expand Down
137 changes: 137 additions & 0 deletions internal/commands/connect_operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ import (
"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
"github.com/basecamp/basecamp-cli/internal/connector/setup"
"github.com/basecamp/basecamp-cli/internal/hostutil"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/richtext"
"github.com/basecamp/basecamp-cli/internal/urlarg"
)

// The operator's commands on a connector's ledger: status, discard, release,
Expand Down Expand Up @@ -451,6 +454,140 @@ outcome is unknown. A lifecycle message still pending for it is not sent.`,
}
}

// --- ask ------------------------------------------------------------------

func newConnectAskCmd() *cobra.Command {
return &cobra.Command{
Use: "ask <url>",
Short: "Declare a post of the agent's as asking the operator for a go",
Long: `Declare a comment, line or post the agent just wrote as an ask: a request for
the operator's go on exactly what it names. The operator's boost on it is then
handed off with "go": true, once. Any other boost on it, and the operator's
second, is an ordinary boosted reply. Only the operator's boost counts, in
every trust mode.

Run it right after posting the ask, with the app_url the post returned
(--json). It reads the post once, as the agent, and refuses one the agent did
not write. The post's content is fingerprinted then: if the post is edited
afterwards, the ask is void and its boosts are only replies. Post a new ask
instead of editing one. A boost decided before the declaration is not a go.
Nothing reads the post's text to decide it is an ask: this declaration is the
only way to make one.`,
Example: ` basecamp connect ask -P agent 'https://3.basecamp.com/999/buckets/456/todos/789#__recording_1011'`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := loadConnectProfile(cmd)
if err != nil {
return err
}
ref, err := askRef(args[0], p)
if err != nil {
return err
}
agent, err := verifiedConnectAgent(cmd.Context(), p)
if err != nil {
return err
}
// The same read admission makes of the boosted recording, so an
// unedited post fingerprints the same at both ends.
post, err := agent.client.ForAccount(agent.account).Recordings().Summarize(cmd.Context(), ref)
if err != nil {
return fmt.Errorf("could not read the ask: %w", err)
}
switch {
case post.Creator == nil || post.Creator.ID != agent.personID:
return output.ErrUsage(fmt.Sprintf("Recording %d is not the agent's own post; only a post the agent wrote can be an ask", ref.RecordingID))
case post.Status != "active":
return output.ErrUsage(fmt.Sprintf("Recording %d is %s; only a published post can be an ask", ref.RecordingID, post.Status))
}
// The boosts already on the post: only one Basecamp gives after
// these, which has a higher id, can be the go. Ids, not times, so
// no clock on this machine is compared with Basecamp's.
boosts, err := agent.client.ForAccount(agent.account).Boosts().ListRecording(cmd.Context(), ref.RecordingID, &basecamp.BoostListOptions{Limit: -1})
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
if err != nil {
return fmt.Errorf("could not read the ask's boosts: %w", err)
}
var floor int64
for _, b := range boosts.Boosts {
floor = max(floor, b.ID)
}
ledger, done, err := openConnectLedger(cmd.Context(), p, false)
if err != nil {
return err
}
defer done()
res, err := ledger.DeclareAsk(cmd.Context(), ref.RecordingID, admission.AskFingerprint(post.Type, post.Title, post.Content), floor, operatorName())
if err != nil {
return err
}
summary := fmt.Sprintf("Recording %d is an ask: the operator's boost on it is the go", ref.RecordingID)
switch {
case res.GoEventID > 0:
summary = fmt.Sprintf("Recording %d was already an ask, and event %d took its go", ref.RecordingID, res.GoEventID)
case res.Edited:
summary = fmt.Sprintf("Recording %d was edited since it was declared: the ask is void. Post a new ask", ref.RecordingID)
case res.Already:
summary = fmt.Sprintf("Recording %d was already an ask", ref.RecordingID)
}
return p.app.OK(res, output.WithSummary(summary))
},
}
}

// askRecordingTypes are the posts an ask can be, by the URL's path type, as
// the summary read names them. A chat line's subtype does not matter to the
// read: every Chat::Lines:: type routes to the same one.
var askRecordingTypes = map[string]string{
"lines": "Chat::Lines::Text",
"messages": "Message",
"todos": "Todo",
"cards": "Kanban::Card",
"documents": "Document",
"question_answers": "Question::Answer",
}

// askRef is the post an ask URL names. A declaration that names the wrong
// recording fails silently later — the operator's boost on the real post is
// then only a reply — so a URL is refused unless it plainly names one post:
// a trusted host, the profile's account, and a single recording (the comment
// a #__recording_ fragment names), never a listing whose id is its parent's.
// A URL, not a bare id: the read routes by the type the URL names.
func askRef(arg string, p connectProfile) (basecamp.RecordingRef, error) {
raw := strings.TrimSpace(arg)
if !urlarg.IsURL(raw) {
return basecamp.RecordingRef{}, output.ErrUsageHint(fmt.Sprintf("No post URL in %q", arg), "Pass the app_url the post returned with --json")
}
if !hostutil.IsTrustedBasecampHost(raw, p.app.Config.BaseURL) {
return basecamp.RecordingRef{}, output.ErrUsage("refusing untrusted host in URL — expected a Basecamp URL")
}
parsed := urlarg.Parse(raw)
var ref basecamp.RecordingRef
var id string
switch {
case parsed == nil:
return ref, output.ErrUsage(fmt.Sprintf("No recording in %q", arg))
case parsed.AccountID != "" && !accountIDsEqual(parsed.AccountID, p.file.AccountID):
return ref, output.ErrUsage(fmt.Sprintf("URL is for account %s, but this agent is in account %s", parsed.AccountID, p.file.AccountID))
case parsed.CommentID != "":
id, ref.RecordingType = parsed.CommentID, "Comment"
case parsed.IsCollection || parsed.RecordingID == "":
return ref, output.ErrUsage("URL names a listing, not the one post the ask is")
default:
typ, ok := askRecordingTypes[parsed.Type]
if !ok {
return ref, output.ErrUsage(fmt.Sprintf("URL names a %s, which cannot be an ask: declare a comment, line, message, to-do, card, document or check-in answer", parsed.Type))
}
id, ref.RecordingType = parsed.RecordingID, typ
}
recording, err := strconv.ParseInt(id, 10, 64)
bucket, bucketErr := strconv.ParseInt(parsed.ProjectID, 10, 64)
if err != nil || recording <= 0 || bucketErr != nil || bucket <= 0 {
return ref, output.ErrUsage(fmt.Sprintf("No project and recording in %q", arg))
}
ref.BucketID, ref.RecordingID = bucket, recording
return ref, nil
}

// --- release --------------------------------------------------------------

func newConnectReleaseCmd() *cobra.Command {
Expand Down
148 changes: 148 additions & 0 deletions internal/commands/connect_operator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -169,6 +170,153 @@ func TestConnectDiscardAndRelease(t *testing.T) {
assert.Equal(t, connector.ReasonByOperator, two.Reason)
}

// askComment is a comment the fake Basecamp serves at /999/comments/<id>.
func (f operatorFixture) askComment(id, creator int64, content string) {
f.s.mu.Lock()
defer f.s.mu.Unlock()
if f.s.bodies == nil {
f.s.bodies = map[string]any{}
}
f.s.bodies[fmt.Sprintf("/999/comments/%d", id)] = map[string]any{
"id": id, "status": "active", "type": "Comment", "title": "Re: a card",
"app_url": fmt.Sprintf("https://3.basecamp.com/999/buckets/456/todos/789#__recording_%d", id),
"bucket": map[string]any{"id": 456, "name": "Connector", "type": "Project"},
"parent": map[string]any{"id": 789, "title": "a card", "type": "Todo"},
"creator": map[string]any{"id": creator, "name": "Marie Chef"},
"content": content,
}
if _, ok := f.s.bodies[fmt.Sprintf("/999/recordings/%d/boosts.json", id)]; !ok {
f.s.bodies[fmt.Sprintf("/999/recordings/%d/boosts.json", id)] = []any{}
}
}

func askHashIn(t *testing.T, f operatorFixture, id int64) string {
t.Helper()
dir, err := connectStatePath(f.file, false)
require.NoError(t, err)
db, err := sql.Open("sqlite", filepath.Join(dir, connector.LedgerFile))
require.NoError(t, err)
defer func() { _ = db.Close() }()
var hash string
err = db.QueryRowContext(context.Background(), `SELECT content_hash FROM asks WHERE recording_id = ?`, id).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
return ""
}
require.NoError(t, err)
return hash
}

func askFloorIn(t *testing.T, f operatorFixture, id int64) int64 {
t.Helper()
dir, err := connectStatePath(f.file, false)
require.NoError(t, err)
db, err := sql.Open("sqlite", filepath.Join(dir, connector.LedgerFile))
require.NoError(t, err)
defer func() { _ = db.Close() }()
var floor int64
require.NoError(t, db.QueryRowContext(context.Background(), `SELECT boost_floor FROM asks WHERE recording_id = ?`, id).Scan(&floor))
return floor
}

// The boosts already on the post set the floor a go's boost must be above.
func TestConnectAskRecordsTheBoostsAlreadyOnThePost(t *testing.T) {
f := newOperatorFixture(t)
require.NoError(t, f.ledger(t, false).Close())
f.askComment(1011, setupAgentPerson, "<div>Shall I?</div>")
f.s.mu.Lock()
f.s.bodies["/999/recordings/1011/boosts.json"] = []any{
map[string]any{"id": 42, "content": "👍", "booster": map[string]any{"id": setupOperatorPerson, "name": "Operator"}},
map[string]any{"id": 7, "content": "🎉", "booster": map[string]any{"id": setupOperatorPerson, "name": "Operator"}},
}
f.s.mu.Unlock()

out, err := f.run(t, output.FormatJSON, "ask", "https://3.basecamp.com/999/buckets/456/todos/789#__recording_1011")
require.NoError(t, err, out)
assert.Equal(t, int64(42), askFloorIn(t, f, 1011))
}

type askOut struct {
Data connector.AskResult `json:"data"`
}

// connect ask reads the comment a URL names, not the recording it hangs
// off, and records the hash of its content exactly as the read returned it.
// Read again unedited, it hashes the same; edited, declaring it again says
// the ask is void and keeps the first hash.
func TestConnectAskFingerprintsTheCommentTheURLNames(t *testing.T) {
f := newOperatorFixture(t)
require.NoError(t, f.ledger(t, false).Close())
const content = "<div>Shall I push abc123 to fix-date-picker?</div>"
f.askComment(1011, setupAgentPerson, content)
url := "https://3.basecamp.com/999/buckets/456/todos/789#__recording_1011"

out, err := f.run(t, output.FormatJSON, "ask", url)
require.NoError(t, err, out)
var first askOut
require.NoError(t, json.Unmarshal([]byte(out), &first), out)
assert.Equal(t, int64(1011), first.Data.RecordingID)
assert.False(t, first.Data.Already)
assert.Equal(t, admission.AskFingerprint("Comment", "", content), askHashIn(t, f, 1011), "the hash is of the content as read")
assert.Zero(t, askFloorIn(t, f, 1011), "no boost was on the post")

// The same post, read again: the same hash, so nothing is edited.
out, err = f.run(t, output.FormatJSON, "ask", url)
require.NoError(t, err, out)
var again askOut
require.NoError(t, json.Unmarshal([]byte(out), &again), out)
assert.True(t, again.Data.Already)
assert.False(t, again.Data.Edited)

// The same account spelled with a leading zero is the same account.
out, err = f.run(t, output.FormatJSON, "ask", "https://3.basecamp.com/0999/buckets/456/todos/789#__recording_1011")
require.NoError(t, err, out)

f.askComment(1011, setupAgentPerson, "<div>Shall I push abc123 and deploy production?</div>")
out, err = f.run(t, output.FormatJSON, "ask", url)
require.NoError(t, err, out)
var edited askOut
require.NoError(t, json.Unmarshal([]byte(out), &edited), out)
assert.True(t, edited.Data.Edited)
assert.Equal(t, admission.AskFingerprint("Comment", "", content), askHashIn(t, f, 1011), "the first declaration stands")
}

// Only a post the agent wrote, named by a URL that plainly names one post in
// the agent's account, is declared; anything else declares nothing.
func TestConnectAskRefusesWhatIsNotTheAgentsOnePost(t *testing.T) {
t.Run("someone else's post", func(t *testing.T) {
f := newOperatorFixture(t)
require.NoError(t, f.ledger(t, false).Close())
f.askComment(1011, setupOperatorPerson, "<div>the operator's comment</div>")
out, err := f.run(t, output.FormatJSON, "ask", "https://3.basecamp.com/999/buckets/456/todos/789#__recording_1011")
require.Error(t, err, out)
assert.Equal(t, output.CodeUsage, usageError(t, err).Code)
assert.Empty(t, askHashIn(t, f, 1011), "nothing was declared")
})

// The agent's own post is served for every id these URLs could resolve
// to, so each is refused by the URL's own check, never by the creator's.
f := newOperatorFixture(t)
require.NoError(t, f.ledger(t, false).Close())
for _, id := range []int64{789, 1011} {
f.askComment(id, setupAgentPerson, "<div>the agent's comment</div>")
}
for name, arg := range map[string]string{
"a bare id": "1011",
"another account": "https://3.basecamp.com/1000/buckets/456/todos/789#__recording_1011",
"an untrusted host": "https://3.basecamp.example.com/999/buckets/456/todos/789#__recording_1011",
"a listing": "https://3.basecamp.com/999/buckets/456/chats/789/lines",
"not a post": "https://3.basecamp.com/999/buckets/456/vaults/789",
} {
t.Run(name, func(t *testing.T) {
out, err := f.run(t, output.FormatJSON, "ask", arg)
require.Error(t, err, out)
assert.Equal(t, output.CodeUsage, usageError(t, err).Code)
})
}
assert.Empty(t, askHashIn(t, f, 789))
assert.Empty(t, askHashIn(t, f, 1011), "nothing was declared")
}

func TestConnectShadowPromoteAndImport(t *testing.T) {
f := newOperatorFixture(t)
require.NoError(t, f.ledger(t, true).Close())
Expand Down
7 changes: 7 additions & 0 deletions internal/commands/connect_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type connectSetupServer struct {
duringMint func()
// mintFailure, when set, answers the stream ticket mint.
mintFailure func(w http.ResponseWriter)
// bodies answers a GET of a path not otherwise served, by path.
bodies map[string]any
// boss is who the agent works for, in its own profile; nil for a plain
// agent, or a Basecamp that does not say.
boss map[string]any
Expand Down Expand Up @@ -234,7 +236,12 @@ func startConnectSetupServer(t *testing.T) *connectSetupServer {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.paths = append(s.paths, r.Method+" "+r.URL.Path)
body, ok := s.bodies[r.URL.Path]
s.mu.Unlock()
if ok && r.Method == http.MethodGet {
writeJSON(w, body)
return
}
http.NotFound(w, r)
})

Expand Down
Loading
Loading