diff --git a/go/internal/linearagent/client.go b/go/internal/linearagent/client.go new file mode 100644 index 000000000..9f1c0c347 --- /dev/null +++ b/go/internal/linearagent/client.go @@ -0,0 +1,342 @@ +package linearagent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// linearOAuthTokenURL is the Linear client-credentials token endpoint. Minting +// requests POST here with grant_type=client_credentials. +const linearOAuthTokenURL = "https://api.linear.app/oauth/token" //nolint:gosec // G101 false positive: a public API endpoint URL, not a credential + +// linearGraphQLURL is the Linear GraphQL endpoint the emitter posts mutations to. +const linearGraphQLURL = "https://api.linear.app/graphql" + +// tokenScope is the pinned OAuth scope string requested on every mint. It MUST +// stay identical across mints: Linear revokes a client-credentials app's +// existing tokens when a mint requests a different scope set, so varying the +// scope here would silently invalidate tokens already in flight. +const tokenScope = "read,write,app:assignable,app:mentionable" //nolint:gosec // G101 false positive: an OAuth scope string, not a credential + +// httpDoer is the injectable HTTP seam. *http.Client satisfies it; tests supply +// an httptest.Server-backed client. +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// ActivityContent is the body of an agentActivityCreate mutation. Per +// linear.app/developers/agent-interaction §Activity content payload it is a +// discriminated union keyed on Type (e.g. "thought"); Body carries the text for +// the content types the responder emits (the one "thought" ack under Option B). +type ActivityContent struct { + Type string `json:"type"` + Body string `json:"body"` +} + +// ExternalURL is a deep-link entry attached to a session via agentSessionUpdate. +type ExternalURL struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// Client emits agent activity and session updates to Linear. The dispatcher +// (T6) calls it; the concrete implementation is *graphQLClient. +type Client interface { + // CreateActivity posts an agent activity (the "thought" ack) to sessionID. + CreateActivity(ctx context.Context, sessionID string, content ActivityContent) error + // UpdateSession attaches externalURLs (the deep link) to sessionID. + UpdateSession(ctx context.Context, sessionID string, externalURLs []ExternalURL) error +} + +// TokenSource mints and caches a Linear client-credentials access token in +// memory. It never persists the token. A re-mint (initial or post-401) is +// coalesced through singleflight so N concurrent callers share one HTTP mint. +type TokenSource struct { + doer httpDoer + tokenURL string + clientID string + clientSecret string + now func() time.Time + + group singleflight.Group + + mu sync.Mutex + token string + expires time.Time +} + +// NewTokenSource builds a TokenSource for the given credentials. doer defaults +// to http.DefaultClient and tokenURL to the Linear OAuth endpoint when zero, so +// production callers pass only the credentials and tests inject both. +func NewTokenSource(clientID, clientSecret string, doer httpDoer, tokenURL string) *TokenSource { + if doer == nil { + doer = http.DefaultClient + } + if tokenURL == "" { + tokenURL = linearOAuthTokenURL + } + return &TokenSource{ + doer: doer, + tokenURL: tokenURL, + clientID: clientID, + clientSecret: clientSecret, + now: time.Now, + } +} + +// Token returns the cached token while unexpired, else mints a fresh one. The +// mint is coalesced: concurrent callers arriving with no valid cached token +// share one HTTP round trip via singleflight. +func (t *TokenSource) Token(ctx context.Context) (string, error) { + if tok, ok := t.cached(); ok { + return tok, nil + } + return t.mint(ctx) +} + +// Invalidate drops the cached token so the next Token mints afresh — the client +// calls it on an observed 401. +func (t *TokenSource) Invalidate() { + t.mu.Lock() + defer t.mu.Unlock() + t.token = "" + t.expires = time.Time{} +} + +// cached returns the live cached token, if any. +func (t *TokenSource) cached() (string, bool) { + t.mu.Lock() + defer t.mu.Unlock() + if t.token != "" && t.now().Before(t.expires) { + return t.token, true + } + return "", false +} + +// tokenResponse is the OAuth token endpoint reply. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +// mint performs (or coalesces onto) a single client-credentials mint and caches +// the result. singleflight collapses a burst of concurrent misses into one HTTP +// call; the shared result is cached by the leader before any caller returns. +func (t *TokenSource) mint(ctx context.Context) (string, error) { + // Re-check under the singleflight leader so a caller that lost the race to a + // just-completed mint reuses the fresh cache instead of minting again. + v, err, _ := t.group.Do("mint", func() (any, error) { + if tok, ok := t.cached(); ok { + return tok, nil + } + return t.doMint(ctx) + }) + if err != nil { + return "", err + } + tok, ok := v.(string) + if !ok { + return "", fmt.Errorf("linear token mint: unexpected singleflight result type %T", v) + } + return tok, nil +} + +// doMint POSTs the client-credentials grant and caches the returned token. +func (t *TokenSource) doMint(ctx context.Context) (string, error) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {t.clientID}, + "client_secret": {t.clientSecret}, + "scope": {tokenScope}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("linear token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := t.doer.Do(req) + if err != nil { + return "", fmt.Errorf("linear token mint: %w", err) + } + defer func() { _ = resp.Body.Close() }() // best-effort close on a read body; nothing actionable on failure + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("linear token read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("linear token mint: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return "", fmt.Errorf("linear token decode: %w", err) + } + if tr.AccessToken == "" { + return "", errors.New("linear token mint: empty access_token") + } + + expires := t.now().Add(time.Hour) + if tr.ExpiresIn > 0 { + expires = t.now().Add(time.Duration(tr.ExpiresIn) * time.Second) + } + + t.mu.Lock() + t.token = tr.AccessToken + t.expires = expires + t.mu.Unlock() + return tr.AccessToken, nil +} + +// graphQLClient implements Client over the Linear GraphQL endpoint, attaching a +// TokenSource bearer and re-minting once on a 401. +type graphQLClient struct { + doer httpDoer + graphQLURL string + tokens *TokenSource +} + +// NewClient builds a Client over the given TokenSource. doer defaults to +// http.DefaultClient and graphQLURL to the Linear GraphQL endpoint when zero. +func NewClient(tokens *TokenSource, doer httpDoer, graphQLURL string) Client { + if doer == nil { + doer = http.DefaultClient + } + if graphQLURL == "" { + graphQLURL = linearGraphQLURL + } + return &graphQLClient{doer: doer, graphQLURL: graphQLURL, tokens: tokens} +} + +// agentActivityCreateMutation posts one agent activity to a session. +const agentActivityCreateMutation = `mutation AgentActivityCreate($input: AgentActivityCreateInput!) { + agentActivityCreate(input: $input) { success } +}` + +// agentSessionUpdateMutation attaches external URLs (the deep link) to a session. +const agentSessionUpdateMutation = `mutation AgentSessionUpdate($id: String!, $input: AgentSessionUpdateInput!) { + agentSessionUpdate(id: $id, input: $input) { success } +}` + +// CreateActivity wraps the agentActivityCreate mutation. +func (c *graphQLClient) CreateActivity(ctx context.Context, sessionID string, content ActivityContent) error { + return c.mutate(ctx, agentActivityCreateMutation, map[string]any{ + "input": map[string]any{ + "agentSessionId": sessionID, + "content": content, + }, + }) +} + +// UpdateSession wraps the agentSessionUpdate mutation. +func (c *graphQLClient) UpdateSession(ctx context.Context, sessionID string, externalURLs []ExternalURL) error { + return c.mutate(ctx, agentSessionUpdateMutation, map[string]any{ + "id": sessionID, + "input": map[string]any{ + "externalUrls": externalURLs, + }, + }) +} + +// graphQLRequest is the GraphQL POST body. +type graphQLRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +// graphQLResponse carries the top-level GraphQL errors array plus each +// mutation's own payload. A mutation can fail two ways at the GraphQL layer: +// a 200 with a populated top-level errors list, or a 200 with an empty errors +// list but a payload success=false. Both mutations select `{ success }`, so a +// false there is an actionable soft failure (under Option B these two emits are +// the entire return path and carry the ack-liveness SLA); Data is keyed by the +// mutation's top-level field name (agentActivityCreate / agentSessionUpdate). +type graphQLResponse struct { + Data map[string]struct { + Success *bool `json:"success"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// mutate posts a GraphQL mutation with the TokenSource bearer, re-minting once +// and retrying once on a 401. +func (c *graphQLClient) mutate(ctx context.Context, query string, variables map[string]any) error { + body, err := json.Marshal(graphQLRequest{Query: query, Variables: variables}) + if err != nil { + return fmt.Errorf("linear mutation encode: %w", err) + } + + resp, err := c.do(ctx, body) + if err != nil { + return err + } + if resp.StatusCode == http.StatusUnauthorized { + // Stale token: drop it, re-mint, and retry exactly once. + _ = resp.Body.Close() // discarding the 401 body before retry; nothing actionable on failure + c.tokens.Invalidate() + resp, err = c.do(ctx, body) + if err != nil { + return err + } + } + defer func() { _ = resp.Body.Close() }() // best-effort close on a read body; nothing actionable on failure + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("linear mutation read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("linear mutation: status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var gr graphQLResponse + if err := json.Unmarshal(respBody, &gr); err != nil { + return fmt.Errorf("linear mutation decode: %w", err) + } + if len(gr.Errors) > 0 { + return fmt.Errorf("linear mutation: %s", gr.Errors[0].Message) + } + // A requested `success` field that is present and false is a soft failure + // (the mutation was accepted at the transport/GraphQL layer but did not take + // effect); surface it like an error so the dispatcher's fallback fires. An + // absent success (nil) is tolerated in case the live schema omits it. + for field, payload := range gr.Data { + if payload.Success != nil && !*payload.Success { + return fmt.Errorf("linear mutation %s: success=false", field) + } + } + return nil +} + +// do issues one authenticated GraphQL POST, attaching the current bearer token. +func (c *graphQLClient) do(ctx context.Context, body []byte) (*http.Response, error) { + token, err := c.tokens.Token(ctx) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphQLURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("linear mutation request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := c.doer.Do(req) + if err != nil { + return nil, fmt.Errorf("linear mutation post: %w", err) + } + return resp, nil +} diff --git a/go/internal/linearagent/client_test.go b/go/internal/linearagent/client_test.go new file mode 100644 index 000000000..c0585a18c --- /dev/null +++ b/go/internal/linearagent/client_test.go @@ -0,0 +1,338 @@ +package linearagent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" +) + +// tokenServer is an httptest.Server standing in for the Linear OAuth token +// endpoint. It counts mints and returns a distinct access token per mint so a +// test can prove which token a caller received. +type tokenServer struct { + srv *httptest.Server + mints atomic.Int64 + // block, when non-nil, gates each mint until closed — the test uses it to + // hold a mint in-flight while concurrent callers pile up on singleflight. + block chan struct{} +} + +func newTokenServer(t *testing.T) *tokenServer { + t.Helper() + ts := &tokenServer{} + ts.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("token endpoint: method = %s, want POST", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Errorf("token endpoint: parse form: %v", err) + } + if got := r.Form.Get("grant_type"); got != "client_credentials" { + t.Errorf("token endpoint: grant_type = %q, want client_credentials", got) + } + if got := r.Form.Get("scope"); got != tokenScope { + t.Errorf("token endpoint: scope = %q, want %q", got, tokenScope) + } + if ts.block != nil { + <-ts.block + } + n := ts.mints.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": tokenName(n), + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + t.Cleanup(ts.srv.Close) + return ts +} + +func tokenName(n int64) string { + return "tok-" + strconv.FormatInt(n, 10) +} + +func TestTokenSourceCachesAcrossCalls(t *testing.T) { + ts := newTokenServer(t) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + for range 5 { + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + if tok != tokenName(1) { + t.Fatalf("Token = %q, want %q (cache should serve the first mint)", tok, tokenName(1)) + } + } + if got := ts.mints.Load(); got != 1 { + t.Fatalf("mints = %d, want 1 (token must be cached across calls)", got) + } +} + +func TestTokenSourceConcurrentMintCoalesces(t *testing.T) { + ts := newTokenServer(t) + ts.block = make(chan struct{}) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + + const callers = 8 + var wg sync.WaitGroup + toks := make([]string, callers) + for i := range callers { + wg.Add(1) + go func(i int) { + defer wg.Done() + tok, err := src.Token(context.Background()) + if err != nil { + t.Errorf("Token: %v", err) + return + } + toks[i] = tok + }(i) + } + // Release the single in-flight mint; all coalesced callers share its result. + close(ts.block) + wg.Wait() + + if got := ts.mints.Load(); got != 1 { + t.Fatalf("mints = %d, want 1 (concurrent callers must coalesce to one mint)", got) + } + for i, tok := range toks { + if tok != tokenName(1) { + t.Fatalf("caller %d token = %q, want %q", i, tok, tokenName(1)) + } + } +} + +// graphQLServer stands in for the Linear GraphQL endpoint. It records the last +// mutation query/variables and, when failFirst401 is set, returns a single 401 +// before succeeding — the stale-token path. +type graphQLServer struct { + srv *httptest.Server + mu sync.Mutex + lastQuery string + lastVars map[string]any + bearers []string + requests atomic.Int64 + failFirst401 atomic.Bool + returnErrors []string + returnStatus int + always401 atomic.Bool + successFalse atomic.Bool +} + +func newGraphQLServer(t *testing.T) *graphQLServer { + t.Helper() + gs := &graphQLServer{} + gs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gs.requests.Add(1) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("graphql: read body: %v", err) + } + var req graphQLRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("graphql: decode: %v", err) + } + gs.mu.Lock() + gs.lastQuery = req.Query + gs.lastVars = req.Variables + gs.bearers = append(gs.bearers, r.Header.Get("Authorization")) + gs.mu.Unlock() + + if gs.failFirst401.CompareAndSwap(true, false) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"unauthorized"}`) + return + } + if gs.always401.Load() { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"unauthorized"}`) + return + } + if gs.returnStatus != 0 { + w.WriteHeader(gs.returnStatus) + _, _ = io.WriteString(w, "boom") + return + } + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{} + if gs.successFalse.Load() { + resp["data"] = map[string]any{"agentActivityCreate": map[string]any{"success": false}} + } + if len(gs.returnErrors) > 0 { + errs := make([]map[string]string, 0, len(gs.returnErrors)) + for _, m := range gs.returnErrors { + errs = append(errs, map[string]string{"message": m}) + } + resp["errors"] = errs + } + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(gs.srv.Close) + return gs +} + +func TestClientCreateActivityIssuesMutation(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err != nil { + t.Fatalf("CreateActivity: %v", err) + } + gs.mu.Lock() + defer gs.mu.Unlock() + if !strings.Contains(gs.lastQuery, "agentActivityCreate") { + t.Fatalf("query = %q, want agentActivityCreate mutation", gs.lastQuery) + } + input, _ := gs.lastVars["input"].(map[string]any) + if input["agentSessionId"] != "sess-1" { + t.Fatalf("agentSessionId = %v, want sess-1", input["agentSessionId"]) + } + content, _ := input["content"].(map[string]any) + if content["type"] != "thought" || content["body"] != "ack" { + t.Fatalf("content = %v, want {type:thought, body:ack}", content) + } +} + +func TestClientUpdateSessionIssuesMutation(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.UpdateSession(context.Background(), "sess-2", []ExternalURL{{Label: "Compass", URL: "https://x/y"}}) + if err != nil { + t.Fatalf("UpdateSession: %v", err) + } + gs.mu.Lock() + defer gs.mu.Unlock() + if !strings.Contains(gs.lastQuery, "agentSessionUpdate") { + t.Fatalf("query = %q, want agentSessionUpdate mutation", gs.lastQuery) + } + if gs.lastVars["id"] != "sess-2" { + t.Fatalf("id = %v, want sess-2", gs.lastVars["id"]) + } + input, _ := gs.lastVars["input"].(map[string]any) + urls, _ := input["externalUrls"].([]any) + if len(urls) != 1 { + t.Fatalf("externalUrls len = %d, want 1", len(urls)) + } + first, _ := urls[0].(map[string]any) + if first["label"] != "Compass" || first["url"] != "https://x/y" { + t.Fatalf("externalUrl = %v, want {label:Compass, url:https://x/y}", first) + } +} + +func TestClientRemintsOnceOn401(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + gs.failFirst401.Store(true) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err != nil { + t.Fatalf("CreateActivity: %v", err) + } + // Two mints: the initial, plus the one re-mint the 401 forced. + if got := ts.mints.Load(); got != 2 { + t.Fatalf("mints = %d, want 2 (a 401 must force exactly one re-mint)", got) + } + // Two GraphQL requests: the 401 and the retry. + if got := gs.requests.Load(); got != 2 { + t.Fatalf("graphql requests = %d, want 2 (401 then retry)", got) + } + gs.mu.Lock() + defer gs.mu.Unlock() + if len(gs.bearers) != 2 { + t.Fatalf("bearers = %v, want 2", gs.bearers) + } + if gs.bearers[0] != "Bearer "+tokenName(1) { + t.Fatalf("first bearer = %q, want first token", gs.bearers[0]) + } + if gs.bearers[1] != "Bearer "+tokenName(2) { + t.Fatalf("retry bearer = %q, want re-minted token", gs.bearers[1]) + } +} + +func TestClientSurfacesNon401Error(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + gs.returnStatus = http.StatusInternalServerError + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err == nil { + t.Fatal("CreateActivity: want error on 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Fatalf("error = %v, want it to mention status 500", err) + } + // A non-401 must not trigger a re-mint or retry. + if got := ts.mints.Load(); got != 1 { + t.Fatalf("mints = %d, want 1 (a non-401 must not re-mint)", got) + } + if got := gs.requests.Load(); got != 1 { + t.Fatalf("graphql requests = %d, want 1 (a non-401 must not retry)", got) + } +} + +func TestClientSurfacesGraphQLErrors(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + gs.returnErrors = []string{"session not found"} + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err == nil || !strings.Contains(err.Error(), "session not found") { + t.Fatalf("error = %v, want it to surface the GraphQL error", err) + } +} + +func TestClientRemintNoLoopOnPersistent401(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + gs.always401.Store(true) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("error = %v, want it to mention status 401", err) + } + // Exactly one re-mint: the initial token plus one forced by the first 401. + if got := ts.mints.Load(); got != 2 { + t.Fatalf("mints = %d, want 2 (a persistent 401 must re-mint exactly once, never loop)", got) + } + // Exactly two GraphQL requests: the original and the single retry — never a third. + if got := gs.requests.Load(); got != 2 { + t.Fatalf("graphql requests = %d, want 2 (re-mint retries once, then surfaces the error)", got) + } +} + +func TestClientSurfacesSuccessFalse(t *testing.T) { + ts := newTokenServer(t) + gs := newGraphQLServer(t) + gs.successFalse.Store(true) + src := NewTokenSource("cid", "csecret", ts.srv.Client(), ts.srv.URL) + c := NewClient(src, gs.srv.Client(), gs.srv.URL) + + // A 200 with empty errors but payload success=false is a soft failure that + // must surface (under Option B this emit is the entire ack return path). + err := c.CreateActivity(context.Background(), "sess-1", ActivityContent{Type: "thought", Body: "ack"}) + if err == nil || !strings.Contains(err.Error(), "success=false") { + t.Fatalf("error = %v, want it to surface the mutation success=false", err) + } +} diff --git a/go/internal/linearagent/webhook.go b/go/internal/linearagent/webhook.go new file mode 100644 index 000000000..17618f813 --- /dev/null +++ b/go/internal/linearagent/webhook.go @@ -0,0 +1,84 @@ +// Package linearagent models Linear's Agent Session webhook payloads and the +// pure verification helpers the responder uses to authenticate and freshness- +// check an inbound webhook. This file (RIG-2717 T1) carries only the envelope +// types plus the signature/timestamp checks; the HTTP handler, the Linear API +// client, and the store live in sibling tasks. +package linearagent + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// SessionEvent models Linear's AgentSessionEventWebhookPayload envelope. +// json tags mirror Linear's camelCase payload keys. +type SessionEvent struct { + Type string `json:"type"` + Action string `json:"action"` + WebhookTimestamp int64 `json:"webhookTimestamp"` + AgentSession AgentSession `json:"agentSession"` + PromptContext string `json:"promptContext"` + AgentActivity AgentActivity `json:"agentActivity"` +} + +// AgentSession is the session subject of the event. +type AgentSession struct { + ID string `json:"id"` + Issue Issue `json:"issue"` + Comment Comment `json:"comment"` + PreviousComments []Comment `json:"previousComments"` + Guidance string `json:"guidance"` +} + +// Issue is the Linear issue the session is attached to. +type Issue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` +} + +// Comment is a Linear comment (the triggering comment or a prior one). +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` +} + +// AgentActivity carries the activity body on prompt-style events. +type AgentActivity struct { + Body string `json:"body"` +} + +// ParseSessionEvent unmarshals a raw webhook body into a SessionEvent. +func ParseSessionEvent(raw []byte) (*SessionEvent, error) { + var ev SessionEvent + if err := json.Unmarshal(raw, &ev); err != nil { + return nil, fmt.Errorf("linearagent: parse session event: %w", err) + } + return &ev, nil +} + +// VerifySignature reports whether headerHex is the HMAC-SHA256 of rawBody under +// secret. It is constant-time and returns false on any hex-decode error. +func VerifySignature(secret []byte, rawBody []byte, headerHex string) bool { + want, err := hex.DecodeString(headerHex) + if err != nil { + return false + } + mac := hmac.New(sha256.New, secret) + mac.Write(rawBody) + return hmac.Equal(want, mac.Sum(nil)) +} + +// CheckTimestamp reports whether the ms-epoch webhookTimestamp is within skew of +// now (in either direction). +func CheckTimestamp(webhookTimestamp int64, now time.Time, skew time.Duration) bool { + ts := time.UnixMilli(webhookTimestamp) + delta := now.Sub(ts) + if delta < 0 { + delta = -delta + } + return delta <= skew +} diff --git a/go/internal/linearagent/webhook_test.go b/go/internal/linearagent/webhook_test.go new file mode 100644 index 000000000..00f131ac7 --- /dev/null +++ b/go/internal/linearagent/webhook_test.go @@ -0,0 +1,160 @@ +package linearagent + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + "time" +) + +func signHex(secret, body []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifySignature(t *testing.T) { + secret := []byte("linear-webhook-secret") + body := []byte(`{"type":"AgentSessionEvent","action":"created"}`) + good := signHex(secret, body) + + if !VerifySignature(secret, body, good) { + t.Fatal("valid signature rejected") + } + + // Tampered body → false. + if VerifySignature(secret, []byte(`{"type":"AgentSessionEvent","action":"prompted"}`), good) { + t.Fatal("tampered body accepted") + } + + // Tampered signature (flip last hex nibble) → false. + tampered := []byte(good) + if tampered[len(tampered)-1] == '0' { + tampered[len(tampered)-1] = '1' + } else { + tampered[len(tampered)-1] = '0' + } + if VerifySignature(secret, body, string(tampered)) { + t.Fatal("tampered signature accepted") + } + + // Wrong secret → false. + if VerifySignature([]byte("other-secret"), body, good) { + t.Fatal("wrong secret accepted") + } + + // Missing header → false. + if VerifySignature(secret, body, "") { + t.Fatal("empty header accepted") + } + + // Non-hex / short header → false (decode error). + if VerifySignature(secret, body, "zzzz") { + t.Fatal("non-hex header accepted") + } + if VerifySignature(secret, body, "abcd") { + t.Fatal("short header accepted") + } +} + +func TestCheckTimestamp(t *testing.T) { + now := time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC) + skew := 5 * time.Minute + + fresh := now.UnixMilli() + if !CheckTimestamp(fresh, now, skew) { + t.Fatal("current timestamp rejected") + } + + // Within skew, past. + if !CheckTimestamp(now.Add(-4*time.Minute).UnixMilli(), now, skew) { + t.Fatal("timestamp within skew (past) rejected") + } + // Within skew, future (clock drift). + if !CheckTimestamp(now.Add(4*time.Minute).UnixMilli(), now, skew) { + t.Fatal("timestamp within skew (future) rejected") + } + + // Stale beyond skew. + if CheckTimestamp(now.Add(-6*time.Minute).UnixMilli(), now, skew) { + t.Fatal("stale timestamp accepted") + } + // Future beyond skew. + if CheckTimestamp(now.Add(6*time.Minute).UnixMilli(), now, skew) { + t.Fatal("far-future timestamp accepted") + } +} + +func TestParseSessionEvent(t *testing.T) { + created := []byte(`{ + "type": "AgentSessionEvent", + "action": "created", + "webhookTimestamp": 1756123200000, + "agentSession": { + "id": "sess_123", + "issue": {"id": "iss_abc", "identifier": "RIG-2717"}, + "comment": {"id": "cmt_1", "body": "please look at this"}, + "previousComments": [], + "guidance": "be concise" + }, + "promptContext": "initial" + }`) + + ev, err := ParseSessionEvent(created) + if err != nil { + t.Fatalf("parse created: %v", err) + } + if ev.Type != "AgentSessionEvent" || ev.Action != "created" { + t.Fatalf("envelope mismatch: %+v", ev) + } + if ev.WebhookTimestamp != 1756123200000 { + t.Fatalf("webhookTimestamp mismatch: %d", ev.WebhookTimestamp) + } + if ev.AgentSession.ID != "sess_123" { + t.Fatalf("agentSession.id mismatch: %q", ev.AgentSession.ID) + } + if ev.AgentSession.Issue.Identifier != "RIG-2717" { + t.Fatalf("issue.identifier mismatch: %q", ev.AgentSession.Issue.Identifier) + } + if ev.AgentSession.Comment.Body != "please look at this" { + t.Fatalf("comment.body mismatch: %q", ev.AgentSession.Comment.Body) + } + if ev.AgentSession.Guidance != "be concise" { + t.Fatalf("guidance mismatch: %q", ev.AgentSession.Guidance) + } + + prompted := []byte(`{ + "type": "AgentSessionEvent", + "action": "prompted", + "webhookTimestamp": 1756123260000, + "agentSession": { + "id": "sess_123", + "issue": {"id": "iss_abc", "identifier": "RIG-2717"}, + "comment": {"id": "cmt_2", "body": "follow up"}, + "previousComments": [{"id": "cmt_1", "body": "please look at this"}], + "guidance": "" + }, + "promptContext": "follow-up", + "agentActivity": {"body": "user prompt text"} + }`) + + ev2, err := ParseSessionEvent(prompted) + if err != nil { + t.Fatalf("parse prompted: %v", err) + } + if ev2.Action != "prompted" { + t.Fatalf("prompted action mismatch: %q", ev2.Action) + } + if ev2.AgentActivity.Body != "user prompt text" { + t.Fatalf("agentActivity.body mismatch: %q", ev2.AgentActivity.Body) + } + if len(ev2.AgentSession.PreviousComments) != 1 || + ev2.AgentSession.PreviousComments[0].ID != "cmt_1" { + t.Fatalf("previousComments mismatch: %+v", ev2.AgentSession.PreviousComments) + } + + if _, err := ParseSessionEvent([]byte("{not json")); err == nil { + t.Fatal("expected parse error on malformed json") + } +}