diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..46ef89fd4 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,23 @@ +{ + "hooks": { + "afterFileEdit": [ + { + "command": "chainloop trace hook cursor after-file-edit", + "timeout": 30 + } + ], + "sessionEnd": [ + { + "command": "chainloop trace hook cursor session-end", + "timeout": 30 + } + ], + "sessionStart": [ + { + "command": "chainloop trace hook cursor session-start", + "timeout": 30 + } + ] + }, + "version": 1 +} diff --git a/.opencode/plugins/chainloop-trace.ts b/.opencode/plugins/chainloop-trace.ts index d6700243d..57befcbcf 100644 --- a/.opencode/plugins/chainloop-trace.ts +++ b/.opencode/plugins/chainloop-trace.ts @@ -1,22 +1,44 @@ import type { Plugin } from "@opencode-ai/plugin" export const ChainloopTrace: Plugin = async ({ $ }) => { - // The commit-msg hook links sessions to commits by cross-referencing - // staged files against AI line attributions recorded by post-tool-use. - // If no file-writing tools (edit, write, apply_patch) are invoked during - // the session, there will be no attributions and the commit will not be - // marked as AI-assisted. const fileWritingTools = ["edit","write","apply_patch"] + const commandTools = ["bash"] - function filePathFromArgs(args: any): string { - if (args?.filePath) return args.filePath - if (args?.path) return args.path - return "" + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] } + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. async function fire(event: string, payload: Record) { const json = JSON.stringify(payload) - await $`echo ${json} | chainloop trace hook opencode ${event}` + try { + await $`echo ${json} | chainloop trace hook opencode ${event}` + } catch (err) { + console.error(`chainloop-trace: ${event} hook failed: ${err}`) + } } return { @@ -31,22 +53,42 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { } }, "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } if (!fileWritingTools.includes(input.tool)) return - await fire("pre-tool-use", { - session_id: input.sessionID, - hook_event_name: "tool.execute.before", - tool_name: input.tool, - file_path: filePathFromArgs(output.args), - }) + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } }, "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } if (!fileWritingTools.includes(input.tool)) return - await fire("post-tool-use", { - session_id: input.sessionID, - hook_event_name: "tool.execute.after", - tool_name: input.tool, - file_path: filePathFromArgs(input.args), - }) + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } }, } } diff --git a/app/cli/internal/trace/claude/announce_test.go b/app/cli/internal/trace/claude/announce_test.go new file mode 100644 index 000000000..02515d797 --- /dev/null +++ b/app/cli/internal/trace/claude/announce_test.go @@ -0,0 +1,96 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package claude + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAnnounceToUser pins the wire shape Claude Code expects. systemMessage +// must stay top-level: nested inside hookSpecificOutput it is silently ignored. +func TestAnnounceToUser(t *testing.T) { + const msg = "Coding Session Available at https://app.chainloop.dev/u/chainloop/sessions/ses_1" + + testCases := []struct { + name string + msg string + wantEmitted bool + }{ + { + name: "a message goes out on both channels", + msg: msg, + wantEmitted: true, + }, + { + name: "nothing to say emits nothing", + msg: "", + wantEmitted: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().AnnounceToUser(tc.msg)) + }) + + if !tc.wantEmitted { + assert.Empty(t, out, "no message means no stdout, so the hook stays a no-op") + return + } + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + + assert.Equal(t, tc.msg, got["systemMessage"], "systemMessage must be top-level") + + hookOut, ok := got["hookSpecificOutput"].(map[string]any) + require.True(t, ok, "hookSpecificOutput must be present") + assert.Equal(t, "PostToolUse", hookOut["hookEventName"]) + + // The model needs the message verbatim to be able to repeat it. + assert.Contains(t, hookOut["additionalContext"], tc.msg) + }) + } +} + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns +// everything written to it. Reads to EOF rather than into a fixed buffer: a +// truncated read would corrupt the payload these tests parse as JSON. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + fn() + require.NoError(t, w.Close()) + + out, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + + return string(out) +} diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index 388952737..3b70ef0b6 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -146,6 +146,12 @@ func (p *Provider) IsCommandTool(toolName string) bool { return slices.Contains(commandTools, toolName) } +// SupportsSystemMessage is true for Claude Code: it renders the +// systemMessage field of a hook response directly to the user. +func (p *Provider) SupportsSystemMessage() bool { + return true +} + // SystemMessage writes a message to stdout for Claude Code to display on session start. func (p *Provider) SystemMessage(msg string) error { if msg == "" { @@ -159,6 +165,39 @@ func (p *Provider) SystemMessage(msg string) error { return json.NewEncoder(os.Stdout).Encode(resp) } +// AnnounceToUser emits a PostToolUse hook response on both of Claude Code's +// delivery channels: systemMessage, which the client prints to the user +// without involving the model, and additionalContext, which reaches the model +// so it can repeat the message in its own reply. +// +// Both are used because only the second is confirmed to render in every +// build. Should systemMessage prove universally reliable, dropping +// additionalContext here would spare the model a turn, and this is the one +// place that would have to change. +func (p *Provider) AnnounceToUser(msg string) error { + if msg == "" { + return nil + } + + type hookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext,omitempty"` + } + + resp := struct { + SystemMessage string `json:"systemMessage"` + HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"` + }{ + SystemMessage: msg, + HookSpecificOutput: hookSpecificOutput{ + HookEventName: eventPostToolUse, + AdditionalContext: "Tell the user the following, including any link verbatim: " + msg, + }, + } + + return json.NewEncoder(os.Stdout).Encode(resp) +} + // ParseSession parses a Claude Code session JSONL and returns structured evidence. func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { jsonlPath, err := findJSONLPath(opts.SessionDir, opts.SessionID) diff --git a/app/cli/internal/trace/cursor/provider.go b/app/cli/internal/trace/cursor/provider.go index dd41f64fe..9010e180e 100644 --- a/app/cli/internal/trace/cursor/provider.go +++ b/app/cli/internal/trace/cursor/provider.go @@ -100,6 +100,19 @@ func (p *Provider) SystemMessage(_ string) error { return nil } +// SupportsSystemMessage is false for Cursor, so callers skip the cost of +// composing a message that SystemMessage would drop. +func (p *Provider) SupportsSystemMessage() bool { + return false +} + +// AnnounceToUser is unsupported for Cursor: it installs only sessionStart, +// sessionEnd and afterFileEdit, so no hook fires after a shell command and +// there is nowhere to deliver the message. +func (p *Provider) AnnounceToUser(_ string) error { + return trace.ErrAnnounceUnsupported +} + // CaptureFileSnapshot is a no-op for Cursor: the afterFileEdit hook // delivers old/new strings directly, so no pre-edit snapshot is needed. func (p *Provider) CaptureFileSnapshot(_ *state.Store, _ *trace.HookInput) error { diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go index 3b61a1cfd..d72ea885b 100644 --- a/app/cli/internal/trace/opencode/provider.go +++ b/app/cli/internal/trace/opencode/provider.go @@ -164,6 +164,20 @@ func (p *Provider) SystemMessage(_ string) error { return nil } +// SupportsSystemMessage is false for opencode, so callers skip the cost of +// composing a message that SystemMessage would drop. +func (p *Provider) SupportsSystemMessage() bool { + return false +} + +// AnnounceToUser is unsupported for OpenCode until its plugin's response +// shape for surfacing a message is verified against a live session, the way +// Claude Code's was. The hook after a shell command already fires, so wiring +// this up later is a change to this method alone. +func (p *Provider) AnnounceToUser(_ string) error { + return trace.ErrAnnounceUnsupported +} + // ParseSession reads the copied export JSON for sessionID and returns // structured evidence. func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index d1e12099f..2d9f6a76c 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -17,12 +17,19 @@ package trace import ( "context" + "errors" "io" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) +// ErrAnnounceUnsupported is returned by AnnounceToUser when the agent has no +// channel for showing the user a message. It means nothing was displayed, as +// opposed to a delivery that was attempted and failed, so a caller holding +// single-use content can keep it rather than throw it away unseen. +var ErrAnnounceUnsupported = errors.New("agent cannot show messages to the user") + // Provider discovers and parses AI coding sessions for a specific agent. // // Providers are stateless singletons from a registry, so the state-touching @@ -97,6 +104,22 @@ type Provider interface { // SystemMessage writes a message to stdout for the agent to display on session start. SystemMessage(msg string) error + + // SupportsSystemMessage reports whether SystemMessage reaches the user + // rather than being discarded. Callers check it before assembling a + // message that costs something to produce, since for agents without + // such a channel that work buys nothing. + SupportsSystemMessage() bool + + // AnnounceToUser writes a hook response to stdout so the agent puts msg + // in front of the user, after a shell command the agent ran. Which + // channel that uses is the provider's business: agents differ in whether + // they render text directly, relay it through the model, or both. + // + // Providers with no way to reach the user return ErrAnnounceUnsupported, + // so callers can tell "shown" apart from "nothing happened" and avoid + // discarding a message nobody saw. + AnnounceToUser(msg string) error } // HookInput represents parsed hook invocation data from an AI agent. diff --git a/app/cli/internal/trace/providers/capabilities_test.go b/app/cli/internal/trace/providers/capabilities_test.go new file mode 100644 index 000000000..4465abd11 --- /dev/null +++ b/app/cli/internal/trace/providers/capabilities_test.go @@ -0,0 +1,74 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package providers + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSupportsSystemMessage pins which agents can actually show the +// session-start banner. Callers use this to decide whether building the +// banner is worth its cost, so a provider claiming support it does not have +// buys a control-plane round trip for a message nobody reads. +func TestSupportsSystemMessage(t *testing.T) { + testCases := []struct { + provider string + want bool + why string + }{ + { + provider: claude.Name, + want: true, + why: "Claude Code renders the systemMessage field of a hook response", + }, + { + provider: cursor.Name, + want: false, + why: "Cursor's hook response has no channel for displaying text", + }, + { + provider: opencode.Name, + want: false, + why: "opencode's plugin system has no equivalent channel", + }, + } + + for _, tc := range testCases { + t.Run(tc.provider, func(t *testing.T) { + p := ByName(tc.provider) + require.NotNil(t, p, "provider %q is not registered", tc.provider) + + assert.Equal(t, tc.want, p.SupportsSystemMessage(), tc.why) + }) + } +} + +// TestSupportsSystemMessageCoversEveryProvider fails when a provider is added +// without a decision recorded above, since the default zero value would +// silently claim no support. +func TestSupportsSystemMessageCoversEveryProvider(t *testing.T) { + known := map[string]bool{claude.Name: true, cursor.Name: true, opencode.Name: true} + + for _, p := range All() { + assert.True(t, known[p.Name()], "provider %q has no SupportsSystemMessage expectation", p.Name()) + } +} diff --git a/app/cli/internal/trace/state/pendinglinks.go b/app/cli/internal/trace/state/pendinglinks.go new file mode 100644 index 000000000..f3af23dc6 --- /dev/null +++ b/app/cli/internal/trace/state/pendinglinks.go @@ -0,0 +1,102 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + // pendingLinksFile holds session links produced by a successful trace + // push, waiting for an agent hook to show them to the user. It lives + // directly in the trace directory, which WipeTraceDir preserves. + pendingLinksFile = "pending-links.json" + + // pendingLinksTTL bounds how long a recorded link stays announceable. + // A push run by the user in their own terminal is never consumed by an + // agent hook, and without an expiry the next agent command, possibly in + // a later session, would announce a link the user has long since seen. + pendingLinksTTL = 10 * time.Minute +) + +// pendingLinks is the on-disk record. SavedAt is RFC3339 UTC, matching the +// timestamp format used by the other records in this package. +type pendingLinks struct { + Links []string `json:"links"` + SavedAt string `json:"saved_at"` +} + +// SavePendingLinks records session links for an agent hook to surface to the +// user. An empty list writes nothing: there is nothing to announce, and a +// leftover empty record would only have to be cleaned up later. +func (s *Store) SavePendingLinks(links []string) error { + if len(links) == 0 { + return nil + } + + data, err := json.Marshal(pendingLinks{Links: links, SavedAt: NowTimestamp()}) + if err != nil { + return fmt.Errorf("encode pending links: %w", err) + } + + base := s.traceDirPath() + if err := os.MkdirAll(base, 0o755); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + return os.WriteFile(filepath.Join(base, pendingLinksFile), data, 0o600) +} + +// PendingLinks returns the recorded session links without consuming them, so +// a caller that turns out to be unable to show them leaves them for whoever +// can. Call ClearPendingLinks once they have actually been shown. +// +// It returns nil when there is nothing recorded, when the record has expired, +// or when it cannot be read: this feeds a cosmetic notification, and no +// failure here is worth surfacing to the caller, let alone failing an agent's +// tool call over. A record that is expired or unparseable is dropped on the +// spot, since nobody can ever use it. +func (s *Store) PendingLinks() []string { + path := filepath.Join(s.traceDirPath(), pendingLinksFile) + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var rec pendingLinks + if err := json.Unmarshal(data, &rec); err != nil { + _ = removeIfExists(path) + return nil + } + + savedAt, err := time.Parse(time.RFC3339, rec.SavedAt) + if err != nil || time.Since(savedAt) > pendingLinksTTL { + _ = removeIfExists(path) + return nil + } + + return rec.Links +} + +// ClearPendingLinks drops the record, so its links are shown at most once. +func (s *Store) ClearPendingLinks() { + _ = removeIfExists(filepath.Join(s.traceDirPath(), pendingLinksFile)) +} diff --git a/app/cli/internal/trace/state/pendinglinks_test.go b/app/cli/internal/trace/state/pendinglinks_test.go new file mode 100644 index 000000000..017cb958c --- /dev/null +++ b/app/cli/internal/trace/state/pendinglinks_test.go @@ -0,0 +1,132 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPendingLinks(t *testing.T) { + links := []string{ + "https://app.chainloop.dev/u/chainloop/sessions/ses_1", + "https://app.chainloop.dev/u/chainloop/sessions/ses_2", + } + + testCases := []struct { + name string + // age of the saved record; zero means "just written" + age time.Duration + links []string + want []string + }{ + { + name: "an empty list is never saved, so nothing comes back", + links: nil, + want: nil, + }, + { + name: "fresh record is returned", + links: links, + want: links, + }, + { + name: "record just inside the TTL is returned", + age: pendingLinksTTL - time.Minute, + links: links, + want: links, + }, + { + name: "record past the TTL is discarded", + age: pendingLinksTTL + time.Minute, + links: links, + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.SavePendingLinks(tc.links)) + if tc.age > 0 { + ageRecord(t, store, tc.age) + } + + got := store.PendingLinks() + assert.Equal(t, tc.want, got) + + // Reading does not consume: a caller that turns out to be unable + // to show them must leave them for one that can. + assert.Equal(t, tc.want, store.PendingLinks(), "reading must not consume") + + store.ClearPendingLinks() + assert.Empty(t, store.PendingLinks(), "clearing must consume") + }) + } +} + +// ageRecord rewrites the saved record with a timestamp shifted into the past, +// so TTL behaviour is exercised without sleeping. +func ageRecord(t *testing.T, store *Store, age time.Duration) { + t.Helper() + + path := filepath.Join(store.traceDirPath(), pendingLinksFile) + raw, err := os.ReadFile(path) + require.NoError(t, err) + + rec := pendingLinks{} + require.NoError(t, json.Unmarshal(raw, &rec)) + rec.SavedAt = time.Now().UTC().Add(-age).Format(time.RFC3339) + + out, err := json.Marshal(rec) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, out, 0o600)) +} + +// TestPendingLinksSurviveWipe pins the assumption the agent-notification +// handoff rests on: the push saves the links and then wipes single-use trace +// state, so a wipe that also cleared this record would silently swallow every +// notification. +func TestPendingLinksSurviveWipe(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + links := []string{"https://app.chainloop.dev/u/chainloop/sessions/ses_1"} + require.NoError(t, store.SavePendingLinks(links)) + + require.NoError(t, store.WipeTraceDir()) + + assert.Equal(t, links, store.PendingLinks(), "links must outlive the post-push wipe") +} + +func TestPendingLinksIgnoresCorruptRecord(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + path := filepath.Join(store.traceDirPath(), pendingLinksFile) + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) + + assert.Empty(t, store.PendingLinks(), "a corrupt record must not surface links") + assert.NoFileExists(t, path, "a corrupt record must still be cleared") +} diff --git a/app/cli/pkg/action/action.go b/app/cli/pkg/action/action.go index bc97ff74f..0d9c7bf19 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -18,6 +18,7 @@ package action import ( "context" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -41,6 +42,16 @@ const ( // defaultAttestationStateFile is the default file name for local attestation state. defaultAttestationStateFile = "chainloop-attestation.tmp.json" + + // dashboardURLTimeout bounds an Infoz lookup made while a person waits on + // a command they ran themselves. + dashboardURLTimeout = 5 * time.Second + + // hookDashboardURLTimeout is the tighter bound for the same lookup inside + // an agent hook, where the wait sits between the developer and their + // first prompt. Missing the banner's destination line costs far less than + // a visible stall, so this gives up quickly. + hookDashboardURLTimeout = 2 * time.Second ) // AttestationStatePath returns the resolved path for local attestation state. @@ -175,14 +186,19 @@ func getCASBackend(ctx context.Context, client pb.AttestationServiceClient, work return casBackendInfo, artifactCASConn.Close, nil } -// fetchUIDashboardURL retrieves the UI Dashboard URL from the control plane -// Returns empty string if not configured or if fetch fails -func fetchUIDashboardURL(ctx context.Context, cpConnection *grpc.ClientConn) string { +// fetchUIDashboardURL retrieves the UI Dashboard URL from the control plane. +// Returns empty string if not configured or if the fetch fails, so callers +// can treat "no dashboard" and "could not ask" the same way. +// +// The caller chooses the timeout, because the acceptable wait depends on +// where this runs: a person waiting on `workflow run describe` will tolerate +// far more than an agent hook holding up someone's first prompt. +func fetchUIDashboardURL(ctx context.Context, cpConnection *grpc.ClientConn, timeout time.Duration) string { if cpConnection == nil { return "" } - tmoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + tmoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() client := pb.NewStatusServiceClient(cpConnection) @@ -194,14 +210,32 @@ func fetchUIDashboardURL(ctx context.Context, cpConnection *grpc.ClientConn) str return resp.UiDashboardUrl } -// buildAttestationViewURL constructs the attestation view URL -// Returns empty string if platformURL is not configured -func buildAttestationViewURL(uiDashboardURL, orgName, digest string) string { - if uiDashboardURL == "" || digest == "" { +// buildDashboardURL constructs a link to a resource page in the web dashboard, +// of the form /u//
/. It returns an empty string when +// the deployment has no dashboard configured or the resource has no ID, which +// is how callers decide whether to show a link at all. The organization and ID +// are path-escaped, since an ID may be an opaque string chosen elsewhere. +func buildDashboardURL(uiDashboardURL, orgName, section, id string) string { + if uiDashboardURL == "" || id == "" { return "" } // Trim trailing slash from platform URL if present uiDashboardURL = strings.TrimRight(uiDashboardURL, "/") - return fmt.Sprintf("%s/u/%s/workflow-runs/%s", uiDashboardURL, orgName, digest) + + return fmt.Sprintf("%s/u/%s/%s/%s", uiDashboardURL, url.PathEscape(orgName), section, url.PathEscape(id)) +} + +// buildAttestationViewURL constructs the attestation view URL +// Returns empty string if platformURL is not configured +func buildAttestationViewURL(uiDashboardURL, orgName, digest string) string { + return buildDashboardURL(uiDashboardURL, orgName, "workflow-runs", digest) +} + +// buildSessionViewURL constructs the URL of an AI coding session's detail page. +// sessionID is the agent's own session ID (Claude Code, Cursor, OpenCode); the +// dashboard resolves a session by that identifier as well as by its Chainloop +// UUID. +func buildSessionViewURL(uiDashboardURL, orgName, sessionID string) string { + return buildDashboardURL(uiDashboardURL, orgName, "sessions", sessionID) } diff --git a/app/cli/pkg/action/action_test.go b/app/cli/pkg/action/action_test.go new file mode 100644 index 000000000..f1991c56a --- /dev/null +++ b/app/cli/pkg/action/action_test.go @@ -0,0 +1,138 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Shared across the action package's tests. +const ( + testDashboardURL = "https://app.chainloop.dev" + testOrgName = "chainloop" + testSessionID = "ses_1" +) + +func TestBuildSessionViewURL(t *testing.T) { + const uuidSessionID = "1d75645b-fd6b-4e7c-9855-f3137add4cf7" + + testCases := []struct { + name string + uiDashboardURL string + orgName string + sessionID string + want string + }{ + { + name: "no dashboard configured", + uiDashboardURL: "", + orgName: testOrgName, + sessionID: uuidSessionID, + want: "", + }, + { + name: "empty session id", + uiDashboardURL: testDashboardURL, + orgName: testOrgName, + sessionID: "", + want: "", + }, + { + name: "uuid session id", + uiDashboardURL: testDashboardURL, + orgName: testOrgName, + sessionID: uuidSessionID, + want: testDashboardURL + "/u/chainloop/sessions/" + uuidSessionID, + }, + { + name: "trailing slash is trimmed", + uiDashboardURL: testDashboardURL + "/", + orgName: testOrgName, + sessionID: uuidSessionID, + want: testDashboardURL + "/u/chainloop/sessions/" + uuidSessionID, + }, + { + // OpenCode session IDs are not UUIDs; kept as a guard that a + // non-UUID agent ID still passes through unchanged. + name: "opencode style session id", + uiDashboardURL: testDashboardURL, + orgName: testOrgName, + sessionID: "ses_8ab12cd34", + want: testDashboardURL + "/u/chainloop/sessions/ses_8ab12cd34", + }, + { + name: "session id is path escaped", + uiDashboardURL: testDashboardURL, + orgName: testOrgName, + sessionID: "a b/c", + want: testDashboardURL + "/u/chainloop/sessions/a%20b%2Fc", + }, + { + name: "org name is path escaped", + uiDashboardURL: testDashboardURL, + orgName: "my org", + sessionID: testSessionID, + want: testDashboardURL + "/u/my%20org/sessions/" + testSessionID, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := buildSessionViewURL(tc.uiDashboardURL, tc.orgName, tc.sessionID) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestAttestationResultGetOrganization covers the accessor RunTracePush feeds +// into the session link, including the nil links in the Status chain. +func TestAttestationResultGetOrganization(t *testing.T) { + testCases := []struct { + name string + res *AttestationResult + want string + }{ + {name: "nil result", res: nil}, + {name: "nil status", res: &AttestationResult{}}, + {name: "nil workflow meta", res: &AttestationResult{Status: &AttestationStatusResult{}}}, + { + name: "organization reported by the control plane", + res: &AttestationResult{Status: &AttestationStatusResult{ + WorkflowMeta: &AttestationStatusWorkflowMeta{Organization: testOrgName}, + }}, + want: testOrgName, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.res.GetOrganization()) + }) + } +} + +// TestBuildAttestationViewURL guards the route the session link now shares a +// builder with, so the refactor cannot silently change it. +func TestBuildAttestationViewURL(t *testing.T) { + const digest = "sha256:abc123" + + want := testDashboardURL + "/u/chainloop/workflow-runs/" + digest + assert.Equal(t, want, buildAttestationViewURL(testDashboardURL, testOrgName, digest)) + assert.Empty(t, buildAttestationViewURL("", testOrgName, digest)) + assert.Empty(t, buildAttestationViewURL(testDashboardURL, testOrgName, "")) +} diff --git a/app/cli/pkg/action/attestation_push.go b/app/cli/pkg/action/attestation_push.go index ea462f618..a706856c3 100644 --- a/app/cli/pkg/action/attestation_push.go +++ b/app/cli/pkg/action/attestation_push.go @@ -63,6 +63,20 @@ type AttestationResult struct { Digest string `json:"digest"` Envelope *dsse.Envelope `json:"envelope"` Status *AttestationStatusResult `json:"status"` + // UIDashboardURL is the base URL of the web dashboard, as reported by the + // control plane at attestation init, or empty when the deployment has no + // UI configured. + UIDashboardURL string `json:"ui_dashboard_url,omitempty"` +} + +// GetOrganization returns the organization the attestation was pushed to as +// reported by the control plane, or an empty string when unknown. Nil-safe. +func (r *AttestationResult) GetOrganization() string { + if r == nil || r.Status == nil || r.Status.WorkflowMeta == nil { + return "" + } + + return r.Status.WorkflowMeta.Organization } type AttestationPush struct { @@ -276,7 +290,8 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru } // Build attestation view URL - attestationResult.Status.AttestationViewURL = buildAttestationViewURL(crafter.CraftingState.UiDashboardUrl, workflow.GetOrganization(), attestationResult.Digest) + attestationResult.UIDashboardURL = crafter.CraftingState.UiDashboardUrl + attestationResult.Status.AttestationViewURL = buildAttestationViewURL(attestationResult.UIDashboardURL, workflow.GetOrganization(), attestationResult.Digest) action.Logger.Info().Msg("push completed") diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 5ef185551..6ed55a10b 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -16,9 +16,11 @@ package action import ( + "context" "errors" "os" "path/filepath" + "strings" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" @@ -55,6 +57,44 @@ func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { return nil } +// sessionStartBanner is what the developer sees at the top of a traced +// session. Its audience includes someone who cloned the repository and never +// ran init, so it says what is happening in plain words and, when we know it, +// where the evidence goes. Chainloop's own vocabulary is deliberately absent: +// "attested" does not tell a newcomer whether something is recorded, +// uploaded, or signed, let alone to where. +// +// Each fact is dropped rather than guessed at when it is unknown, so the +// banner never promises a destination that was not confirmed. +func sessionStartBanner(dashboardURL, org, project string) string { + var identity []string + if org != "" { + identity = append(identity, "organization: "+org) + } + if project != "" { + identity = append(identity, "project: "+project) + } + where := strings.Join(identity, ", ") + + // Destination and identity share a line, since they answer one question + // between them: where this is going. The space before the parenthesis + // matters, as it is what lets a terminal linkify the URL without + // swallowing the punctuation that follows it. + switch { + case dashboardURL != "" && where != "": + where = "Evidence will be sent to " + strings.TrimRight(dashboardURL, "/") + " (" + where + ")" + case dashboardURL != "": + where = "Evidence will be sent to " + strings.TrimRight(dashboardURL, "/") + } + + banner := "Chainloop Trace is recording this session." + if where != "" { + banner += "\n" + where + } + + return banner +} + // HandleAgentSessionStart handles the agent session-start hook. func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error { input, err := provider.ReadHookInput(os.Stdin) @@ -73,13 +113,48 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error ensureSessionTracked(provider, store, repoRoot, input, log) - if err := provider.SystemMessage("\n\n*** This session will be attested by Chainloop ***"); err != nil { + // Composing the banner costs a control-plane round trip, so it is only + // worth doing for an agent that can put it in front of the user. Cursor + // and opencode would discard it, and the developer would have paid the + // wait for nothing. + if !provider.SupportsSystemMessage() { + return nil + } + + banner := sessionStartBanner( + hookDashboardURL(log), + config.LoadOrganizationFromYML(repoRoot), + config.LoadProjectFromYML(repoRoot), + ) + + if err := provider.SystemMessage("\n\n" + banner + "\n"); err != nil { log.Debug().Err(err).Msg("session-start: failed to send system message") } return nil } +// hookDashboardURL asks the control plane where its web dashboard lives, so +// the session banner can name the destination the evidence is bound for. +// Returns an empty string when there is no dashboard, no reachable control +// plane, or no time to find out, in which case the banner simply omits the +// line. +// +// This is the one network call the session-start hook makes, and it is +// deliberately cheap to abandon: Infoz needs no credentials, so no token is +// loaded, and the timeout is short because a developer waiting to type is a +// worse cost than a missing line. +func hookDashboardURL(log zerolog.Logger) string { + conn, err := newControlPlaneConnection("", "") + if err != nil { + log.Debug().Err(err).Msg("session-start: no control plane connection for the banner") + return "" + } + defer func() { _ = conn.Close() }() + + return fetchUIDashboardURL(context.Background(), conn, hookDashboardURLTimeout) +} + // HandleAgentPreToolUse handles the agent pre-tool-use hook. func HandleAgentPreToolUse(provider trace.Provider, log zerolog.Logger) error { input, err := provider.ReadHookInput(os.Stdin) @@ -173,6 +248,49 @@ func ensureSessionTracked(provider trace.Provider, store *state.Store, repoRoot } } +// notifyPendingSessionLinks hands any session links left by a just-completed +// trace push to the agent, so it can put them in front of the user. +// +// Best effort throughout: this is a notification, and neither a missing link +// nor a provider that cannot deliver one is worth failing an agent's tool +// call over. +func notifyPendingSessionLinks(provider trace.Provider, store *state.Store, log zerolog.Logger) { + links := store.PendingLinks() + if len(links) == 0 { + return + } + + // Same wording as the pre-push log line, so the two channels match. + lines := make([]string, 0, len(links)) + for _, link := range links { + lines = append(lines, sessionLinkMessage(link)) + } + + err := provider.AnnounceToUser(strings.Join(lines, "\n")) + if errors.Is(err, trace.ErrAnnounceUnsupported) { + // Nothing was shown, so leave the links for an agent that can show + // them. Their expiry bounds how long they linger. + log.Debug().Msg("agent cannot show messages; leaving the session links for later") + + return + } + + if err != nil { + log.Debug().Err(err).Msg("could not surface session links through the agent") + } else { + log.Debug().Int("links", len(links)).Msg("session links handed to the agent") + } + + // Success or failure, the attempt is spent: retrying on every later shell + // command would nag far longer than one dropped notification costs. + // + // Announcing before clearing makes this at-least-once by choice. The + // agent never acknowledges what it rendered, so exactly-once is not + // available at any price, and the other ordering trades a repeated line + // for a link nobody ever sees. + store.ClearPendingLinks() +} + // HandleAgentPostToolUse handles post-edit hooks across providers // (Claude's post-tool-use, Cursor's afterFileEdit) and records AI-attributed // line ranges for the edited file. @@ -213,6 +331,12 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { // every file the command changed to the AI. recordCommandLineRanges(store, repoRoot, sessionID, log) + // The command may have been a `git push`, whose pre-push hook attested + // a session and left its link behind. Show it now: the pre-push output + // went to this tool call's captured stderr, which the user does not + // necessarily read. + notifyPendingSessionLinks(provider, store, log) + return nil } diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index ff02c72b4..ea970e791 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -122,7 +122,7 @@ func TestHandleAgentSessionStart(t *testing.T) { }) assert.Contains(t, stdout, `"systemMessage"`) - assert.Contains(t, stdout, "This session will be attested by Chainloop") + assert.Contains(t, stdout, "Chainloop Trace is recording this session.") }) t.Run("ignores malformed stdin", func(t *testing.T) { diff --git a/app/cli/pkg/action/trace_attestation.go b/app/cli/pkg/action/trace_attestation.go index 945c94f17..dddbbcb68 100644 --- a/app/cli/pkg/action/trace_attestation.go +++ b/app/cli/pkg/action/trace_attestation.go @@ -282,11 +282,12 @@ func (e *AttestationExecutor) Reset(ctx context.Context, trigger, reason string) return nil } -// Push finalizes and pushes the attestation. -func (e *AttestationExecutor) Push(ctx context.Context) error { +// Push finalizes and pushes the attestation, returning the push result so +// callers can report where the evidence landed. +func (e *AttestationExecutor) Push(ctx context.Context) (*AttestationResult, error) { cliVersion, cliDigest, err := e.executableInfo() if err != nil { - return fmt.Errorf("resolve executable info: %w", err) + return nil, fmt.Errorf("resolve executable info: %w", err) } a, err := NewAttestationPush(&AttestationPushOpts{ @@ -299,14 +300,15 @@ func (e *AttestationExecutor) Push(ctx context.Context) error { CLIDigest: cliDigest, }) if err != nil { - return fmt.Errorf("create attestation push action: %w", err) + return nil, fmt.Errorf("create attestation push action: %w", err) } - if _, err := a.Run(ctx, "", nil, false); err != nil { - return fmt.Errorf("attestation push: %w", err) + res, err := a.Run(ctx, "", nil, false) + if err != nil { + return nil, fmt.Errorf("attestation push: %w", err) } - return nil + return res, nil } // executableInfo returns the CLI version and SHA-256 digest of the running CLI binary diff --git a/app/cli/pkg/action/trace_banner_test.go b/app/cli/pkg/action/trace_banner_test.go new file mode 100644 index 000000000..95f1f4fbb --- /dev/null +++ b/app/cli/pkg/action/trace_banner_test.go @@ -0,0 +1,165 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bannerProvider is a real provider with its banner capability forced, so a +// test can drive both sides of the gate without a second agent. +type bannerProvider struct { + trace.Provider + + supports bool + sysCalls int +} + +func (p *bannerProvider) SupportsSystemMessage() bool { return p.supports } + +func (p *bannerProvider) SystemMessage(string) error { + p.sysCalls++ + + return nil +} + +// TestSessionStartBanner pins what the developer is told at the top of a +// session. The audience includes a teammate who cloned the repository and +// never ran init, so the banner has to say what is happening and where the +// evidence goes without assuming any Chainloop vocabulary. +func TestSessionStartBanner(t *testing.T) { + const ( + recording = "Chainloop Trace is recording this session." + testProject = "chainloop-cli" + ) + + testCases := []struct { + name string + dashboardURL string + org string + project string + want string + }{ + { + name: "nothing known says only what is happening", + want: recording, + }, + { + name: "destination is named when there is a dashboard", + dashboardURL: testDashboardURL, + want: recording + "\nEvidence will be sent to " + testDashboardURL, + }, + { + name: "trailing slash is trimmed", + dashboardURL: testDashboardURL + "/", + want: recording + "\nEvidence will be sent to " + testDashboardURL, + }, + { + // Destination and identity share one line, and the URL keeps a + // space after it so a terminal linkifies it without swallowing + // the parenthesis. + name: "organization and project qualify the destination", + dashboardURL: testDashboardURL, + org: testOrgName, + project: testProject, + want: recording + + "\nEvidence will be sent to " + testDashboardURL + + " (organization: " + testOrgName + ", project: " + testProject + ")", + }, + { + name: "identity stands alone when no dashboard is configured", + org: testOrgName, + project: testProject, + want: recording + "\norganization: " + testOrgName + ", project: " + testProject, + }, + { + name: "project alone", + project: testProject, + want: recording + "\nproject: " + testProject, + }, + { + name: "organization alone", + org: testOrgName, + want: recording + "\norganization: " + testOrgName, + }, + { + name: "dashboard with only a project", + dashboardURL: testDashboardURL, + project: testProject, + want: recording + + "\nEvidence will be sent to " + testDashboardURL + + " (project: " + testProject + ")", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, sessionStartBanner(tc.dashboardURL, tc.org, tc.project)) + }) + } +} + +// TestSessionStartBannerGate checks that an agent which cannot display the +// banner is never handed one. Building it costs a control-plane round trip, +// which is not worth paying for a string the agent throws away. +func TestSessionStartBannerGate(t *testing.T) { + testCases := []struct { + name string + supports bool + wantSent int + }{ + { + name: "an agent that shows messages gets the banner", + supports: true, + wantSent: 1, + }, + { + name: "an agent that discards them is not asked", + supports: false, + wantSent: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + repoDir := initTempGitRepo(t) + store := state.NewGitStore(filepath.Join(repoDir, ".git")) + require.NoError(t, store.InitTraceDir()) + + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + withStdin(t, `{"session_id":"abc-123"}`) + + p := &bannerProvider{Provider: claude.New(), supports: tc.supports} + require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + + assert.Equal(t, tc.wantSent, p.sysCalls) + assert.True(t, store.SessionRecordExists("abc-123"), "the session is tracked either way") + }) + } +} diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index ba8db8540..b1d7a893b 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -285,6 +285,11 @@ type RunTracePushOpts struct { // only on CLI flags. Pre-push hook callers leave it false to keep // reading the repo config. IgnoreYAML bool + // SkipAgentNotification suppresses recording session links for an agent + // hook to show later. `trace run` sets it: it reaches the push only + // after the agent it wrapped has exited, so no hook of that agent can + // fire again, and its own terminal already showed the links. + SkipAgentNotification bool // ActionOpts is the root command's initialized options, used to build // the attestation executor. Required: the push cannot run without it. @@ -553,18 +558,18 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts log.Debug().Str("attestation_id", attestationID).Msg("attestation initialized") // Add evidence for each session - var addedCount int + attestedSessions := make([]string, 0, len(evidenceFiles)) for _, ef := range evidenceFiles { name := evidenceName(ef.sessionID) if err := executor.AddEvidence(ctx, name, ef.tmpPath); err != nil { log.Debug().Err(err).Str("session", ef.sessionID).Msg("could not add evidence") continue } - addedCount++ + attestedSessions = append(attestedSessions, ef.sessionID) log.Debug().Str("session", ef.sessionID).Str("name", name).Msg("evidence added") } - if addedCount == 0 { + if len(attestedSessions) == 0 { log.Debug().Msg("no evidence successfully added, resetting attestation") _ = executor.Reset(ctx, "trace-push", "no CHAINLOOP_AI_CODING_SESSION evidence added") @@ -573,10 +578,32 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts // Push attestation log.Debug().Msg("pushing attestation") - if err := executor.Push(ctx); err != nil { + res, err := executor.Push(ctx) + if err != nil { return fmt.Errorf("attestation push: %w", err) } + // Tell the user where each session landed. The organization comes from the + // control plane rather than opts.Organization, which is empty whenever the + // CLI's current org is used. + links := logAttestedSessions(log, res.UIDashboardURL, res.GetOrganization(), attestedSessions) + + // Hand the links to the agent hook that runs after this push. When the + // push was driven by a coding agent's shell tool, the log line above is + // captured into that tool's output rather than shown to the user, so the + // hook is what actually puts the link in front of them. A failure here + // costs a notification, never the attestation that already succeeded. + // + // The caller tells us whether to skip, rather than us inferring it from + // on-disk state: the trace-run sentinel outlives a killed run, and + // reading it here would silently suppress every later notification in + // that repository. + if opts.SkipAgentNotification { + log.Debug().Msg("caller already showed the links; not recording them for an agent hook") + } else if err := store.SavePendingLinks(links); err != nil { + log.Debug().Err(err).Msg("could not record session links for the agent hook") + } + log.Debug().Msg("attestation pushed, wiping single-use trace state") // Mark every AI commit included in this attestation as tracked so that a @@ -605,6 +632,35 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts return nil } +// logAttestedSessions reports one line per attested session. When the +// deployment has a UI dashboard configured the line points at the session's +// page, with the link inline so it reads as a sentence and stays clickable in +// a terminal. Without a dashboard the line still names the session, so the +// user gets confirmation of what was recorded either way. +// It returns the links it logged, so the caller can hand them to the agent +// hook that will show them to the user. +func logAttestedSessions(log zerolog.Logger, uiDashboardURL, orgName string, sessionIDs []string) []string { + links := make([]string, 0, len(sessionIDs)) + for _, id := range sessionIDs { + // The link already ends in the session ID, so a session field + // alongside it would only repeat itself in the rendered line. + if url := buildSessionViewURL(uiDashboardURL, orgName, id); url != "" { + log.Info().Msg(sessionLinkMessage(url)) + links = append(links, url) + continue + } + log.Info().Str("session", id).Msg("Coding session attested") + } + + return links +} + +// sessionLinkMessage is the one place the user-facing wording lives, so the +// pre-push log line and the agent's notification cannot drift apart. +func sessionLinkMessage(url string) string { + return "Coding Session Available at " + url +} + // evidenceName returns the material name for a session evidence document. // Format: ai-coding-session-. Underscores are // replaced with hyphens because material names may only contain lowercase diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go index 76fba30d8..4e9482f52 100644 --- a/app/cli/pkg/action/trace_hook_handler_test.go +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -16,6 +16,8 @@ package action import ( + "bytes" + "encoding/json" "os" "os/exec" "path/filepath" @@ -690,3 +692,86 @@ func requireFileAttribution(t *testing.T, changes *aicodingsession.CodeChanges, } require.Failf(t, "file not found in CodeChanges", "%s (got %v)", path, changes.Files) } + +// sessionLogEntry is the subset of a logAttestedSessions log line under test. +type sessionLogEntry struct { + Session string `json:"session"` + Message string `json:"message"` +} + +func TestLogAttestedSessions(t *testing.T) { + // Deliberately not testOrgName: an organization that differs from the one + // used elsewhere, and that changes under escaping, proves the logged URL + // is built from the argument rather than from any incidental value. + const ( + orgName = "acme corp" + orgInPath = "acme%20corp" + sessionURL = testDashboardURL + "/u/" + orgInPath + "/sessions/" + linkPrefix = "Coding Session Available at " + ) + + testCases := []struct { + name string + uiDashboardURL string + orgName string + sessionIDs []string + want []sessionLogEntry + }{ + { + name: "one line per session, each linked to the given org", + uiDashboardURL: testDashboardURL, + orgName: orgName, + sessionIDs: []string{testSessionID, "ses_2"}, + want: []sessionLogEntry{ + {Message: linkPrefix + sessionURL + testSessionID}, + {Message: linkPrefix + sessionURL + "ses_2"}, + }, + }, + { + name: "no dashboard still confirms the session, without a link", + uiDashboardURL: "", + orgName: orgName, + sessionIDs: []string{testSessionID}, + want: []sessionLogEntry{{Session: testSessionID, Message: "Coding session attested"}}, + }, + { + name: "no sessions logs nothing", + uiDashboardURL: testDashboardURL, + orgName: orgName, + sessionIDs: nil, + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + log := zerolog.New(&buf).Level(zerolog.InfoLevel) + + gotLinks := logAttestedSessions(log, tc.uiDashboardURL, tc.orgName, tc.sessionIDs) + + // The returned links are what gets handed to the agent hook, so + // they must match the links that were logged, and nothing is + // returned when there is no dashboard to link to. + wantLinks := []string{} + for _, w := range tc.want { + if link, found := strings.CutPrefix(w.Message, linkPrefix); found { + wantLinks = append(wantLinks, link) + } + } + assert.Equal(t, wantLinks, gotLinks) + + var got []sessionLogEntry + for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") { + if line == "" { + continue + } + var entry sessionLogEntry + require.NoError(t, json.Unmarshal([]byte(line), &entry)) + got = append(got, entry) + } + + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/app/cli/pkg/action/trace_notify_links_test.go b/app/cli/pkg/action/trace_notify_links_test.go new file mode 100644 index 000000000..2280431ec --- /dev/null +++ b/app/cli/pkg/action/trace_notify_links_test.go @@ -0,0 +1,137 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "errors" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingProvider captures what notifyPendingSessionLinks hands to the agent. +type recordingProvider struct { + trace.Provider + + calls int + announced string + err error +} + +func (p *recordingProvider) AnnounceToUser(msg string) error { + p.calls++ + p.announced = msg + + return p.err +} + +func TestNotifyPendingSessionLinks(t *testing.T) { + const ( + link1 = "https://app.chainloop.dev/u/chainloop/sessions/ses_1" + link2 = "https://app.chainloop.dev/u/chainloop/sessions/ses_2" + ) + + testCases := []struct { + name string + saved []string + providerErr error + wantCalls int + wantUserMessage string + }{ + { + name: "nothing pending leaves the agent alone", + saved: nil, + wantCalls: 0, + }, + { + name: "one link is announced with the log line's wording", + saved: []string{link1}, + wantCalls: 1, + wantUserMessage: "Coding Session Available at " + link1, + }, + { + name: "several links are announced one per line", + saved: []string{link1, link2}, + wantCalls: 1, + wantUserMessage: "Coding Session Available at " + link1 + "\n" + + "Coding Session Available at " + link2, + }, + { + name: "a failed delivery is not fatal, and does not nag", + saved: []string{link1}, + providerErr: errors.New("stdout closed"), + wantCalls: 1, + wantUserMessage: "Coding Session Available at " + link1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.SavePendingLinks(tc.saved)) + + p := &recordingProvider{err: tc.providerErr} + + // Must not panic or propagate; it only ever notifies. + notifyPendingSessionLinks(p, store, zerolog.Nop()) + + assert.Equal(t, tc.wantCalls, p.calls) + assert.Equal(t, tc.wantUserMessage, p.announced) + + // A second hook firing must stay silent once an agent has had + // its go, even if delivery failed: re-announcing on every later + // shell command is worse than dropping one notification. + before := p.calls + notifyPendingSessionLinks(p, store, zerolog.Nop()) + + assert.Equal(t, before, p.calls, "shown links must be consumed exactly once") + assert.Empty(t, store.PendingLinks()) + }) + } +} + +// TestNotifyPendingSessionLinksKeepsUnshownLinks covers the inverse of the +// consume-once rule: an agent with no way to reach the user showed nobody +// anything, so discarding the links would lose them silently. +func TestNotifyPendingSessionLinksKeepsUnshownLinks(t *testing.T) { + const link = "https://app.chainloop.dev/u/chainloop/sessions/ses_1" + + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.SavePendingLinks([]string{link})) + + unsupported := &recordingProvider{err: trace.ErrAnnounceUnsupported} + + notifyPendingSessionLinks(unsupported, store, zerolog.Nop()) + + assert.Equal(t, 1, unsupported.calls) + assert.Equal(t, []string{link}, store.PendingLinks(), "unshown links must stay on disk") + + // The point of keeping them: an agent that can show them gets its turn + // later, and consumes them as usual. + capable := &recordingProvider{} + + notifyPendingSessionLinks(capable, store, zerolog.Nop()) + + assert.Equal(t, 1, capable.calls, "a capable agent must still be offered the links") + assert.Equal(t, "Coding Session Available at "+link, capable.announced) + assert.Empty(t, store.PendingLinks(), "once shown, the links are consumed") +} diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index ff1019259..83f924e7c 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -189,14 +189,15 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error log.Debug().Msg("wrapped command completed; attesting session") return RunTracePush(ctx, log, RunTracePushOpts{ - AllowEmpty: true, - ProjectName: opts.ProjectName, - Organization: opts.Organization, - WorkflowName: opts.WorkflowName, - ProjectVersion: opts.ProjectVersion, - IgnoreYAML: true, - ActionOpts: opts.ActionOpts, - CLIVersion: opts.CLIVersion, + AllowEmpty: true, + ProjectName: opts.ProjectName, + Organization: opts.Organization, + WorkflowName: opts.WorkflowName, + ProjectVersion: opts.ProjectVersion, + IgnoreYAML: true, + SkipAgentNotification: true, + ActionOpts: opts.ActionOpts, + CLIVersion: opts.CLIVersion, }) } diff --git a/app/cli/pkg/action/workflow_run_describe.go b/app/cli/pkg/action/workflow_run_describe.go index b2891eef9..0f64ff021 100644 --- a/app/cli/pkg/action/workflow_run_describe.go +++ b/app/cli/pkg/action/workflow_run_describe.go @@ -285,7 +285,7 @@ func (action *WorkflowRunDescribe) Run(ctx context.Context, opts *WorkflowRunDes } var attestationViewURL string - baseUIDashboardURL := fetchUIDashboardURL(ctx, action.cfg.CPConnection) + baseUIDashboardURL := fetchUIDashboardURL(ctx, action.cfg.CPConnection, dashboardURLTimeout) if baseUIDashboardURL != "" { attestationViewURL = buildAttestationViewURL(baseUIDashboardURL, resp.GetResult().GetOrgName(), att.DigestInCasBackend) }