From 1b4bc1e5c926e5e4614a16d9fee05b51e8634232 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 16 Oct 2026 01:30:21 +0200 Subject: [PATCH] fix(trace): report rejected CLI credentials at session start The session-start hook now asks the control plane whether the CLI credentials are accepted, with the same check as `chainloop trace init`. When the control plane rejects them, for example an expired token, the developer is told at session start, not at push time after the session work is done. Claude Code shows the warning in the session banner. Cursor and OpenCode tell the model to relay it. The check runs only in control-plane mode and is bounded to 2 seconds. An unreachable control plane shows no warning. Refs PFM-7719 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: a42f8781-cc5f-4c1a-ad40-2bbf68b908ec --- app/cli/cmd/trace_hook.go | 43 +++++++-- app/cli/internal/trace/README.md | 2 + app/cli/pkg/action/action.go | 4 + app/cli/pkg/action/trace_agent_hook.go | 92 +++++++++++++++++-- app/cli/pkg/action/trace_agent_hook_test.go | 24 ++--- app/cli/pkg/action/trace_banner_test.go | 83 ++++++++++++++++- app/cli/pkg/action/trace_local_store_test.go | 2 +- .../pkg/action/trace_shell_checkout_test.go | 4 +- .../pkg/action/trace_skills_scenario_test.go | 2 +- app/cli/pkg/action/trace_worktree_test.go | 14 ++- 10 files changed, 231 insertions(+), 39 deletions(-) diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index b1caecf57..26da0805e 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -16,6 +16,9 @@ package cmd import ( + "context" + + "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/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" @@ -183,13 +186,37 @@ func newTraceHookClaudeSessionStartCmd() *cobra.Command { Annotations: map[string]string{ "skipActionOptsInit": "true", }, - RunE: func(_ *cobra.Command, _ []string) error { - InitHookLogger() - return action.HandleAgentSessionStart(claude.New(), logger) + RunE: func(cmd *cobra.Command, _ []string) error { + return runAgentSessionStart(cmd, claude.New()) }, } } +// runAgentSessionStart handles the session-start hook of any agent. In +// control-plane mode, the hook asks the control plane whether the credentials +// are accepted, with the same check as `chainloop trace init`, so that the +// session start can report a credentials problem before the push. +func runAgentSessionStart(cmd *cobra.Command, provider trace.Provider) error { + InitHookLogger() + + checkAuth := func(ctx context.Context, organization string) error { + // This command is skipActionOptsInit, so root did not build the + // control-plane connection. A local-mode session never gets here. + if err := ensureActionOpts(cmd); err != nil { + return err + } + + executor, err := openTraceExecutor(ctx, organization) + if err != nil { + return err + } + + return executor.Close() + } + + return action.HandleAgentSessionStart(provider, checkAuth, logger) +} + func newTraceHookClaudeUserPromptSubmitCmd() *cobra.Command { return &cobra.Command{ Use: "user-prompt-submit", @@ -270,9 +297,8 @@ func newTraceHookCursorSessionStartCmd() *cobra.Command { Annotations: map[string]string{ "skipActionOptsInit": "true", }, - RunE: func(_ *cobra.Command, _ []string) error { - InitHookLogger() - return action.HandleAgentSessionStart(cursor.New(), logger) + RunE: func(cmd *cobra.Command, _ []string) error { + return runAgentSessionStart(cmd, cursor.New()) }, } } @@ -364,9 +390,8 @@ func newTraceHookOpenCodeSessionStartCmd() *cobra.Command { Annotations: map[string]string{ "skipActionOptsInit": "true", }, - RunE: func(_ *cobra.Command, _ []string) error { - InitHookLogger() - return action.HandleAgentSessionStart(opencode.New(), logger) + RunE: func(cmd *cobra.Command, _ []string) error { + return runAgentSessionStart(cmd, opencode.New()) }, } } diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 8277af1da..fa8d84a1d 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -49,10 +49,12 @@ What the user sees in the agent. |---------|---------------|-------------|--------|--------------|------------| | Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and where the evidence goes: the Chainloop project, or the on-disk path in local mode (spec issue-3582). | Yes | No | No | No | | Session link after push | After a `git push` that the agent runs, the user sees a link to the session in Chainloop. | Yes | No | No | No | +| Credentials warning | At session start, in control-plane mode, the user is told when the control plane does not accept the CLI credentials (for example, an expired token), so the push will not upload the session. The hook uses the same check as `chainloop trace init`. An unreachable control plane, or a check that takes more than 2 seconds, shows no warning. | Yes | Partial | Partial | Partial | Why not Yes: - **Welcome message, Cursor and OpenCode:** the provider gives the session-start context to the model only. It has no channel that shows a message to the user at session start, so the welcome message is dropped. +- **Credentials warning, Cursor and OpenCode:** the provider has no channel to the user at session start. The model is told to tell the user, so the warning depends on the model. - **Session link after push, Cursor:** the `postToolUse` hook runs after a shell command, but its response has no field that Cursor shows to the user. The link stays pending until it expires. - **Session link after push, OpenCode:** the plugin hook fires after a shell command, but the response that shows a message to the user is not verified yet. The link stays pending until it expires. diff --git a/app/cli/pkg/action/action.go b/app/cli/pkg/action/action.go index d8de0205c..9df3fab4a 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -52,6 +52,10 @@ const ( // first prompt. Missing the banner's destination line costs far less than // a visible stall, so this gives up quickly. hookDashboardURLTimeout = 2 * time.Second + + // hookAuthCheckTimeout bounds the credentials check of the session-start + // hook, for the same reason. A check that takes longer shows no warning. + hookAuthCheckTimeout = 2 * time.Second ) // AttestationStatePath returns the resolved path for local attestation state. diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 7b37713ae..c52faa89f 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -170,8 +170,68 @@ func localSessionStartBanner(tracePath string, traceRunActive bool) string { return banner } -// HandleAgentSessionStart handles the agent session-start hook. -func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error { +// TraceAuthCheck asks the control plane whether the CLI credentials are +// accepted for the organization. An empty organization uses the default one. +type TraceAuthCheck func(ctx context.Context, organization string) error + +// sessionAuthProblem runs checkAuth and returns the message the CLI shows for +// an authentication failure, or an empty string. The control plane decides +// whether the credentials are valid. Any other failure, for example an +// unreachable control plane or a check that takes longer than the timeout, +// returns an empty string: the hook does not guess. +func sessionAuthProblem(checkAuth TraceAuthCheck, organization string, log zerolog.Logger) string { + if checkAuth == nil { + return "" + } + + ctx, cancel := context.WithTimeout(context.Background(), hookAuthCheckTimeout) + defer cancel() + + // The check can ignore ctx, so it runs in its own goroutine. The hook + // process exits soon after, so an abandoned check stops with it. + done := make(chan error, 1) + go func() { done <- checkAuth(ctx, organization) }() + + var err error + select { + case err = <-done: + case <-ctx.Done(): + log.Debug().Msg("session-start: the auth check timed out") + return "" + } + + msg, isAuth := AuthErrorMessage(err) + if !isAuth { + if err != nil { + log.Debug().Err(err).Msg("session-start: the auth check failed") + } + + return "" + } + + return msg +} + +// authProblemBanner tells the developer that the session will not be uploaded +// because the control plane does not accept the credentials. The push is the +// first step that uses them, and it comes after the session work is done. +func authProblemBanner(authMsg string) string { + return "Chainloop Trace cannot upload this session when you push: " + authMsg + "." +} + +// authProblemInstruction is the authProblemBanner for an agent that has no +// banner. The model is the only channel to the developer. +func authProblemInstruction(authMsg string) string { + return "Chainloop Trace cannot upload this session when the user pushes, because the Chainloop CLI credentials are not accepted: " + + authMsg + ". Tell the user now, in one line, to fix this before they push." +} + +// HandleAgentSessionStart handles the agent session-start hook. In +// control-plane mode, checkAuth asks the control plane whether the credentials +// that the pre-push hook will use are accepted, so that the developer knows +// about a problem before the session work is done. A nil checkAuth skips the +// check. +func HandleAgentSessionStart(provider trace.Provider, checkAuth TraceAuthCheck, log zerolog.Logger) error { input, err := provider.ReadHookInput(os.Stdin) if err != nil || !state.ValidSessionID(input.SessionID) { log.Debug().Err(err).Msg("session-start: no valid input") @@ -201,18 +261,29 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error // discards it would make the developer pay the wait for nothing. var msg trace.SessionStartMessage + // A `trace run` session takes its mode from the run. Other sessions take + // it from traceStorage in .chainloop.yml. + local := config.IsLocalStorage(repoRoot) + if store.IsTraceRunActive() { + local = store.IsTraceRunLocal() + } + + // Local mode uploads nothing, so only the control-plane mode needs + // credentials. + var authMsg string + if !local { + authMsg = sessionAuthProblem(checkAuth, config.LoadOrganizationFromYML(repoRoot), log) + } + if provider.SupportsSessionStartInstruction() { msg.Instruction = sessionSpecInstruction(repoRoot, input.SessionID, provider.SupportsPromptReminder(), log) + // An agent with a banner tells the developer directly. + if authMsg != "" && !provider.SupportsSessionStartBanner() { + msg.Instruction = strings.TrimSpace(authProblemInstruction(authMsg) + "\n\n" + msg.Instruction) + } } if provider.SupportsSessionStartBanner() { - // A `trace run` session takes its mode from the run. Other sessions - // take it from traceStorage in .chainloop.yml. - local := config.IsLocalStorage(repoRoot) - if store.IsTraceRunActive() { - local = store.IsTraceRunLocal() - } - var banner string if local { // Local mode reaches no control plane, so the banner names the @@ -227,6 +298,9 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error config.LoadOrganizationFromYML(repoRoot), repositoryconfig.LoadProjectFromYML(repoRoot), ) + if authMsg != "" { + banner += "\n\n" + authProblemBanner(authMsg) + } } msg.Banner = "\n\n" + banner + "\n" } diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index 4354f6233..5cb186c3d 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -62,7 +62,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`) - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) assert.True(t, store.SessionRecordExists("abc-123")) }) @@ -83,7 +83,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"abc-123"}`) - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) assert.True(t, store.SessionRecordExists("abc-123")) }) @@ -104,7 +104,7 @@ func TestHandleAgentSessionStart(t *testing.T) { // Start a new session withStdin(t, `{"session_id":"session-2"}`) - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) // Both sessions should have records assert.True(t, store.SessionRecordExists("session-1")) @@ -123,7 +123,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"msg-test","cwd":"/some/path"}`) stdout := captureStdout(t, func() { - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) assert.Contains(t, stdout, `"systemMessage"`) @@ -160,7 +160,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"mode-test"}`) stdout := captureStdout(t, func() { - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) if tc.wantLocal { @@ -182,7 +182,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"spec-test"}`) stdout := captureStdout(t, func() { - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) // Claude Code parses a hook's stdout as a single JSON value, so the two @@ -216,7 +216,7 @@ func TestHandleAgentSessionStart(t *testing.T) { withStdin(t, `{"session_id":"spec-done"}`) stdout := captureStdout(t, func() { - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) var got map[string]any @@ -230,12 +230,12 @@ func TestHandleAgentSessionStart(t *testing.T) { t.Run("ignores malformed stdin", func(t *testing.T) { withStdin(t, `not json`) - assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + assert.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) t.Run("ignores empty session ID", func(t *testing.T) { withStdin(t, `{"session_id":""}`) - assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + assert.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) } @@ -393,7 +393,7 @@ func TestHandleAgentSessionEnd(t *testing.T) { })) withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`) - require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) rec, err := store.LoadSessionRecord("abc-123") require.NoError(t, err) @@ -785,7 +785,7 @@ func TestHandleAgentClaudeCodeSession(t *testing.T) { withStdin(t, fmt.Sprintf( `{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, sid, tp, root)) - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) require.True(t, store.SessionRecordExists(sid), "SessionStart should record the session") // 2. Write a brand-new file. Claude fires PreToolUse (file does not exist @@ -947,7 +947,7 @@ func TestHandleAgentCommandTool_OtherCheckout(t *testing.T) { } withStdin(t, fmt.Sprintf(`{"session_id":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, sid, home)) - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) // 1. The agent writes a file in the other checkout, twice. modal := filepath.Join(other, "modal.tsx") diff --git a/app/cli/pkg/action/trace_banner_test.go b/app/cli/pkg/action/trace_banner_test.go index 2797a0a8c..a3e4b8d4f 100644 --- a/app/cli/pkg/action/trace_banner_test.go +++ b/app/cli/pkg/action/trace_banner_test.go @@ -16,16 +16,22 @@ package action import ( + "context" "os" "path/filepath" "testing" + "time" "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/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // bannerProvider is a real provider with its session-start capability forced, @@ -177,7 +183,7 @@ func TestSessionStartChannelGate(t *testing.T) { withStdin(t, `{"session_id":"abc-123"}`) p := &bannerProvider{Provider: claude.New(), banner: tc.banner, instruction: tc.instruction} - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) assert.Equal(t, tc.wantSent, p.sysCalls) if tc.wantBanner { @@ -194,3 +200,78 @@ func TestSessionStartChannelGate(t *testing.T) { }) } } + +// TestSessionStartAuthProblem checks that credentials that the control plane +// does not accept are reported at the start of the session. Otherwise the +// developer learns about it only when the push does not upload the session. +func TestSessionStartAuthProblem(t *testing.T) { + expired := func(context.Context, string) error { + return status.Error(codes.Unauthenticated, jwtMiddleware.ErrTokenExpired.Message) + } + const wantMsg = `your authentication token has expired, please run "chainloop auth login" again` + + testCases := []struct { + name string + checkAuth TraceAuthCheck + local bool + banner bool + wantWarning bool + }{ + {name: "an auth failure is reported in the banner", checkAuth: expired, banner: true, wantWarning: true}, + {name: "an auth failure is reported in the instruction when there is no banner", checkAuth: expired, wantWarning: true}, + {name: "accepted credentials are not reported", checkAuth: func(context.Context, string) error { return nil }, banner: true}, + {name: "an unreachable control plane is not reported", checkAuth: func(context.Context, string) error { + return status.Error(codes.Unavailable, "connection refused") + }, banner: true}, + {name: "no check is not reported", banner: true}, + {name: "local mode does not check", checkAuth: func(context.Context, string) error { + return status.Error(codes.Unauthenticated, jwtMiddleware.ErrTokenExpired.Message) + }, local: true, banner: true}, + } + + 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()) + if tc.local { + require.NoError(t, config.SaveTraceStorageToYML(repoDir, config.TraceStorageLocal)) + } + t.Chdir(repoDir) + + withStdin(t, `{"session_id":"abc-123"}`) + + p := &bannerProvider{Provider: claude.New(), banner: tc.banner, instruction: true} + require.NoError(t, HandleAgentSessionStart(p, tc.checkAuth, zerolog.Nop())) + + if !tc.wantWarning { + assert.NotContains(t, p.last.Banner, wantMsg) + assert.NotContains(t, p.last.Instruction, wantMsg) + return + } + + if tc.banner { + assert.Contains(t, p.last.Banner, wantMsg) + assert.NotContains(t, p.last.Instruction, wantMsg, "the banner already tells the developer") + } else { + assert.Contains(t, p.last.Instruction, wantMsg) + } + }) + } +} + +// TestSessionAuthProblemTimeout checks that a slow auth check does not hold +// the session start, and shows no warning. +func TestSessionAuthProblemTimeout(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + + slow := func(context.Context, string) error { + <-release + return status.Error(codes.Unauthenticated, jwtMiddleware.ErrTokenExpired.Message) + } + + start := time.Now() + assert.Empty(t, sessionAuthProblem(slow, "", zerolog.Nop())) + assert.Less(t, time.Since(start), hookAuthCheckTimeout+time.Second) +} diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 8e8d8e881..0ec8019ec 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -258,7 +258,7 @@ func TestRunTraceLocalStorePartialFailure(t *testing.T) { }{sessionID, "SessionStart", tr.path, repoDir}) require.NoError(t, err) withStdin(t, string(payload)) - captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(claude.New(), zerolog.Nop())) }) + captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(claude.New(), nil, zerolog.Nop())) }) sha := headSHA(t, repoDir) require.NoError(t, store.SaveCommitRecord(&state.CommitRecord{ diff --git a/app/cli/pkg/action/trace_shell_checkout_test.go b/app/cli/pkg/action/trace_shell_checkout_test.go index 7c4e9369c..76fec4873 100644 --- a/app/cli/pkg/action/trace_shell_checkout_test.go +++ b/app/cli/pkg/action/trace_shell_checkout_test.go @@ -83,7 +83,7 @@ func TestClaudeSessionCommitsInClonedCheckoutThroughShell(t *testing.T) { } withStdin(t, fmt.Sprintf(`{"session_id":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, sid, mainRoot)) - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) // 1. The agent writes a file in a folder that is not a git repository. const specBody = "# Spec\n\nline\n" @@ -199,7 +199,7 @@ func TestShellCommandInOtherCheckout(t *testing.T) { command := fmt.Sprintf("cd %s && sed -i '' s/a/b/ notes.md", other) withStdin(t, tc.start) - captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) }) + captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) }) withStdin(t, tc.pre(command)) captureStdout(t, func() { require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) }) diff --git a/app/cli/pkg/action/trace_skills_scenario_test.go b/app/cli/pkg/action/trace_skills_scenario_test.go index 1c519bdf1..cc12448d9 100644 --- a/app/cli/pkg/action/trace_skills_scenario_test.go +++ b/app/cli/pkg/action/trace_skills_scenario_test.go @@ -117,7 +117,7 @@ func TestSkillScenario(t *testing.T) { // The session starts. tr.append(`{"type":"user","uuid":"u1","timestamp":"2026-10-07T10:00:00.000Z","sessionId":"` + sessionID + `","message":{"role":"user","content":"Rewrite the PR description in STE."}}`) withStdin(t, hookPayload("SessionStart", "")) - captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) }) + captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop())) }) // The model calls the Skill tool, Claude Code loads the skill, and the // post-tool-use hook fires. diff --git a/app/cli/pkg/action/trace_worktree_test.go b/app/cli/pkg/action/trace_worktree_test.go index 38ebc8d9f..7ca0d89cd 100644 --- a/app/cli/pkg/action/trace_worktree_test.go +++ b/app/cli/pkg/action/trace_worktree_test.go @@ -32,6 +32,12 @@ import ( "github.com/stretchr/testify/require" ) +// sessionStartNoToken is HandleAgentSessionStart with no CLI token, in the +// signature that the other agent hooks share. +func sessionStartNoToken(p trace.Provider, log zerolog.Logger) error { + return HandleAgentSessionStart(p, nil, log) +} + // PFM-7412: a Claude Code session started in a traced repository forks its // work into two git worktrees and commits/pushes each one as its own PR. // @@ -91,7 +97,7 @@ func TestClaudeSessionForkedIntoWorktrees(t *testing.T) { require.NoError(t, handler(p, zerolog.Nop())) } - hook("SessionStart", `,"source":"startup"`, HandleAgentSessionStart) + hook("SessionStart", `,"source":"startup"`, sessionStartNoToken) // The agent forks the work into two worktrees, one per PR. worktrees := map[string]string{} @@ -223,7 +229,7 @@ func TestClaudeTranscriptFoundFromWorktreeCwd(t *testing.T) { require.NoError(t, handler(p, zerolog.Nop())) } - hook(mainRoot, "SessionStart", `,"source":"startup"`, HandleAgentSessionStart) + hook(mainRoot, "SessionStart", `,"source":"startup"`, sessionStartNoToken) wt := filepath.Join(mainRoot, ".claude", "worktrees", "agent-abc123") runGit(t, mainRoot, "worktree", "add", "-b", "feat-wt", wt) @@ -287,7 +293,7 @@ func TestEnsureSessionTracked_BackfillsTranscriptPath(t *testing.T) { p := claude.New() withStdin(t, fmt.Sprintf(`{"session_id":%q,"cwd":%q,"hook_event_name":"SessionStart"}`, sid, root)) - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) rec, err := store.LoadSessionRecord(sid) require.NoError(t, err) @@ -319,7 +325,7 @@ func TestHandleAgentSessionEnd_BackfillsTranscriptPath(t *testing.T) { p := claude.New() withStdin(t, fmt.Sprintf(`{"session_id":%q,"cwd":%q,"hook_event_name":"SessionStart"}`, sid, root)) - require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop())) transcript := filepath.Join(claudeProjectDir(home, "/elsewhere"), sid+".jsonl") withStdin(t, fmt.Sprintf(`{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"SessionEnd"}`, sid, transcript, root))