0f1081d423
* feat(share): add public share links via GitHub Gists Adds a Share button to the chat panel header that publishes the current session as a secret GitHub Gist on the user's account. The share link routes through gistpreview.github.io to render share.html as a styled kandev-themed page; snapshot.json + README.md ride along for machine-readable + fallback views. Snapshots run a redaction pass (absolute paths, common credential shapes, .env contents, shell env vars) and strip <kandev-system> prompt blocks via the shared sysprompt helper. Message filtering whitelists user prose + tool_* types so internal noise never leaks into shares. Spec at docs/specs/public-share-links/spec.md. * fix(share): coerce pendingClarification to bool for ClarificationSection A late refactor extracted ClarificationSection with `pendingClarification: boolean` on its props, but the call site passes the actual Message|null value. CI's tsc build catches the mismatch; runtime was unaffected because the section only uses the value in a truthiness check. Coerce with Boolean() at the call site to keep the prop's intent narrow. * fix(share): address PR #995 review feedback Bugs (flagged by Greptile, Cubic, Claude): - abs-path redaction split on `root + "/"` so sibling dirs like /home/user/proj2 don't get corrupted when workspaceRoot is /home/user/proj; regression test added. - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so share.IsAlreadyGone matches consistently with the PAT client; this restores the "gist already revoked upstream" soft-success path for CLI-authenticated users. - SnapshotSizeBytes is now measured from the same MarshalIndent payload the gist backend uploads, so the size shown in the UI matches the actual gist bytes (was ~20-40% underreported). Polish (flagged by CodeRabbit, Cubic): - Tool name + tool output now HTML-escaped; tool call args and output render via <pre><code> instead of triple-backtick fences so payloads containing ``` can't break out of the code block. - share-list.tsx: clear `busy` via finally and break error layout out of the justify-between row. - use-shares.ts: convert UseSharesResult to interface (repo TS rules); reset error+isLoading when ids are missing. - use-shares.test.tsx: wait on the actual async outcome (shares populated, listShares called) instead of isLoading flipping false. - provider.go: replace `repo.Close` cleanup with an explicit noop closure so the comment ("Cleanup is a no-op") and the impl match. - spec.md: aligned the shareability rule across What/Failure/Scenarios/ Out-of-scope — any non-prehistory state can be shared; added language tags to fenced code blocks for markdownlint. Skipped intentionally (reply on each thread): - HTML-escape every metadata badge: the values are controlled enum strings from kandev's own schema, not user input. Defense-in-depth noise. Tool name (which can come from agent runtime) IS escaped. - useShares request-token guard against stale responses: the dialog owns the hook lifecycle and opens against a single (taskId, sessionId) — the race the comment describes can't occur here. * fix(share): redact paths for every worktree, not just the first A single TaskSession can carry multiple worktrees when the task spans multiple repos. The redactor previously only saw the first worktree path, so absolute paths from secondary worktrees leaked into public shares instead of being rewritten to repo-relative form. NewRedactor is now variadic and applies the abs-path rule against every root it receives, sorted longest-first so a root that is a prefix of another (e.g. /workspace vs /workspace/repo1) gets matched by the more specific one first. workspaceRootFor in builder.go becomes workspaceRootsFor and emits every WorktreePath plus the WorkspacePath fallback. Flagged by Cubic on PR #995. * fix(share): address PR #995 review round 3 Bugs (flagged by Cubic + Claude on gh_client.go:714): - DeleteGist now calls the existing isNotFoundErr helper instead of an inline strings.Contains(err, "HTTP 404"). The helper also covers "404 Not Found" and "status: 404", which some gh versions emit, so the share service's "gist already revoked upstream" soft-success path now fires consistently across CLI auth users. Bugs (flagged by Claude on builder.go:220): - toolCallBlock used to read metadata["tool_name"] and metadata["args"] but production never writes those keys; the typed payload lives under metadata["normalized"] (a *streams.NormalizedPayload) and the tool kind is on Message.Type. Switched to deriving ToolName from strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit", …) and reading args from metadata["normalized"], with backward-compat fallback to the older "tool_name"/"args" keys for the debug-fixture replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape exercises the real session shape. Bugs (flagged by CodeRabbit on redaction.go:110): - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted paths) used to leak because the matcher only handled root + "/". Switched to a precompiled regex per root — regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone `root` get stripped, while sibling dirs like /workspace2 still don't match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the bare-root variants. Doc (flagged by Claude on spec.md): - Two malformed fence closers (`go and `text on closing fences) corrected to plain ``` so the file is markdownlint-clean. * fix(share): narrow abs-path boundary to avoid sibling-prefix corruption Round 4 review (Claude + CodeRabbit) caught a regression in the regex introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch bare-root cases, but `\b` fires on any word↔non-word transition. So a sibling path like `/workspace.bak`, `/workspace-old`, or `/workspace-other` would false-match the `/workspace` root (since e→. and e→- are word boundaries), stripping it and leaving a corrupted `.bak` / `-old/file.ts` in the share. Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or end-of-string only — never punctuation or quote chars. The boundary is captured and re-emitted via ReplaceAllStringFunc (or dropped, in the `/` case) so prose loses only the root, not the surrounding space. Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering `.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes. The quoted-bare-root case (`"/workspace"`) no longer matches and was dropped from the standalone-root test — contrived enough that a small leak there is preferable to corrupting realistic sibling paths. * refactor(share): move share button into chat status bar Replaces the dedicated PanelHeaderBarSplit above the message list with a slot in ChatStatusBar (the existing row holding TodoIndicator and PRStatusChip just above the chat input). The button anchors right via ml-auto; when the "proceed to next step" affordance is present it takes the right anchor instead and the share button stays inline. Also tightens the iconOnly variant in ShareButton to a ghost style that matches the muted-foreground chips in the status bar, and exports shareableSessionStateClient from share-button.tsx so the gating logic lives next to the entry point it controls. Net -23 lines in task-chat-panel.tsx: the extra header bar and the local gate helper are gone. * docs(share): broaden tooltip + add share/ to CLAUDE.md tree The share tooltip read "Share this completed task with a public link", but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT / FAILED / CANCELLED too. A user hovering mid-run saw misleading copy; reworded to "Share this task conversation as a public link" which matches the actual gate. Also added the missing internal/task/share/ entry to the task/ subtree in CLAUDE.md so the new package shows up in the repo map — flagged across multiple rounds. * docs: add internal/task/share/ to repo map Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to) was missing the share/ subpackage under task/. Adding it now so the repo map matches what's checked in. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
280 lines
9.2 KiB
Go
280 lines
9.2 KiB
Go
package share
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/sysprompt"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// ErrSessionNotShareable is returned when BuildSnapshot is asked to snapshot
|
|
// a session that has not produced any conversation yet (CREATED / STARTING).
|
|
// The HTTP handler maps this to 409.
|
|
var ErrSessionNotShareable = errors.New("session has no shareable content yet")
|
|
|
|
// TaskReader is the narrow slice of the task repository BuildSnapshot needs.
|
|
// Keeping it small lets us stub it in unit tests without pulling in the full
|
|
// task repo interface.
|
|
type TaskReader interface {
|
|
GetTask(ctx context.Context, id string) (*models.Task, error)
|
|
GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
|
|
ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error)
|
|
}
|
|
|
|
// BuildSnapshot loads the task, session, and messages for the given session
|
|
// and returns a redacted snapshot. The kandevVersion is recorded in the
|
|
// snapshot for forward debugging but is otherwise opaque.
|
|
//
|
|
// Sessions in CREATED or STARTING state are rejected because they predate
|
|
// any agent output. Every other state — RUNNING, IDLE, WAITING_FOR_INPUT,
|
|
// COMPLETED, FAILED, CANCELLED — produces a valid snapshot of whatever
|
|
// conversation has happened so far.
|
|
func BuildSnapshot(ctx context.Context, repo TaskReader, taskSessionID, kandevVersion string) (*Snapshot, error) {
|
|
session, err := repo.GetTaskSession(ctx, taskSessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load session: %w", err)
|
|
}
|
|
if session == nil {
|
|
return nil, fmt.Errorf("load session: %w", ErrSessionNotShareable)
|
|
}
|
|
if !isShareableState(session.State) {
|
|
return nil, ErrSessionNotShareable
|
|
}
|
|
|
|
task, err := repo.GetTask(ctx, session.TaskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load task: %w", err)
|
|
}
|
|
|
|
messages, err := repo.ListMessages(ctx, taskSessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load messages: %w", err)
|
|
}
|
|
|
|
red := NewRedactor(workspaceRootsFor(session)...)
|
|
snap := &Snapshot{
|
|
Version: SnapshotVersion,
|
|
KandevVersion: kandevVersion,
|
|
ExportedAt: time.Now().UTC(),
|
|
Task: taskMeta(task),
|
|
Session: sessionMeta(session),
|
|
Messages: mapMessages(messages, red),
|
|
}
|
|
snap.Redaction.AppliedRules = red.Applied()
|
|
return snap, nil
|
|
}
|
|
|
|
// isShareableState mirrors the frontend gate: any session that has
|
|
// progressed past the pre-history states (CREATED, STARTING) is fair game.
|
|
func isShareableState(s models.TaskSessionState) bool {
|
|
switch s {
|
|
case models.TaskSessionStateCreated, models.TaskSessionStateStarting:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func taskMeta(t *models.Task) TaskMeta {
|
|
if t == nil {
|
|
return TaskMeta{}
|
|
}
|
|
return TaskMeta{
|
|
Title: t.Title,
|
|
WorkflowStep: t.WorkflowStepID,
|
|
}
|
|
}
|
|
|
|
func sessionMeta(s *models.TaskSession) SessionMeta {
|
|
return SessionMeta{
|
|
AgentType: snapshotString(s.AgentProfileSnapshot, "agent_type"),
|
|
Model: snapshotString(s.AgentProfileSnapshot, "model"),
|
|
ExecutorType: snapshotString(s.ExecutorSnapshot, "type"),
|
|
StartedAt: s.StartedAt,
|
|
CompletedAt: s.CompletedAt,
|
|
}
|
|
}
|
|
|
|
// workspaceRootsFor returns every filesystem root the redactor should rewrite
|
|
// to repo-relative form. Multi-repo sessions carry multiple worktrees, so we
|
|
// emit each WorktreePath plus the session's WorkspacePath fallback — the
|
|
// redactor sorts them longest-first internally to handle nested roots.
|
|
func workspaceRootsFor(s *models.TaskSession) []string {
|
|
roots := make([]string, 0, len(s.Worktrees)+1)
|
|
for _, w := range s.Worktrees {
|
|
if w != nil && w.WorktreePath != "" {
|
|
roots = append(roots, w.WorktreePath)
|
|
}
|
|
}
|
|
if s.WorkspacePath != "" {
|
|
roots = append(roots, s.WorkspacePath)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// snapshotString extracts a string field from one of the *Snapshot map
|
|
// metadata fields stored on TaskSession. Returns "" if the field is missing
|
|
// or is not a string.
|
|
func snapshotString(m map[string]interface{}, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
// mapMessages converts the live message rows into snapshot Messages.
|
|
// Hidden system content (wrapped in <kandev-system>) is stripped per the
|
|
// existing Message.ToAPI() convention before redaction.
|
|
func mapMessages(in []*models.Message, red *Redactor) []Message {
|
|
out := make([]Message, 0, len(in))
|
|
for _, m := range in {
|
|
role := roleForAuthor(m.AuthorType, m.Type)
|
|
blocks := blocksForMessage(m, red)
|
|
if len(blocks) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Message{
|
|
Role: role,
|
|
Ts: m.CreatedAt,
|
|
Blocks: blocks,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func roleForAuthor(author models.MessageAuthorType, mt models.MessageType) string {
|
|
switch author {
|
|
case models.MessageAuthorUser:
|
|
return roleUser
|
|
case models.MessageAuthorAgent:
|
|
return roleAssistant
|
|
}
|
|
if mt == models.MessageTypeStatus || mt == models.MessageTypeLog || mt == models.MessageTypeError {
|
|
return roleSystem
|
|
}
|
|
return roleSystem
|
|
}
|
|
|
|
// blocksForMessage decides whether a Message is shareable conversation
|
|
// content and converts it into the snapshot Blocks for that role. We
|
|
// whitelist what gets through rather than blacklist noise — that way new
|
|
// internal message types (tool_search, agent_plan, todo, mock-agent stderr
|
|
// captures, …) don't quietly leak into shared snapshots.
|
|
//
|
|
// What gets through:
|
|
// - User messages of any type → text block (the user's intent is always
|
|
// part of the conversation, even if the runtime tagged it oddly).
|
|
// - Agent messages with no explicit type, "message", or "content"
|
|
// → text block (regular agent prose).
|
|
// - Agent messages whose type starts with "tool_" (tool_call, tool_edit,
|
|
// tool_read, tool_execute, tool_search, …) → collapsed tool block.
|
|
//
|
|
// Everything else — status, progress, log, error, thinking, agent_plan,
|
|
// todo, permission_request, clarification_request, script_execution, plus
|
|
// any future type we don't recognise — is dropped.
|
|
func blocksForMessage(m *models.Message, red *Redactor) []Block {
|
|
if m.AuthorType == models.MessageAuthorUser {
|
|
return textBlockOrNil(red, m.Content)
|
|
}
|
|
t := string(m.Type)
|
|
if t == "" || t == string(models.MessageTypeMessage) || t == string(models.MessageTypeContent) {
|
|
return textBlockOrNil(red, m.Content)
|
|
}
|
|
if strings.HasPrefix(t, "tool_") {
|
|
return []Block{toolCallBlock(m, red)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// textBlockOrNil returns a single text block, or nil if the content is empty
|
|
// after trimming. System-injected blocks wrapped in <kandev-system> tags
|
|
// (the system prompt the runtime prepends to user turns) are stripped first
|
|
// — those carry implementation details that should never appear in a public
|
|
// share. After stripping, if nothing is left we drop the message entirely.
|
|
func textBlockOrNil(red *Redactor, content string) []Block {
|
|
stripped := sysprompt.StripSystemContent(content)
|
|
text := strings.TrimSpace(stripped)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
return []Block{{Kind: blockKindText, Text: red.String(text)}}
|
|
}
|
|
|
|
// toolCallBlock builds a "tool_call" Block from a tool-call message. The
|
|
// message Content carries a human-readable summary; the Args, when present
|
|
// in Metadata, are redacted as a top-level JSON object so RuleEnvVars fires
|
|
// for shell payloads.
|
|
func toolCallBlock(m *models.Message, red *Redactor) Block {
|
|
b := Block{
|
|
Kind: blockKindToolCall,
|
|
Text: red.String(strings.TrimSpace(m.Content)),
|
|
ToolName: toolNameFor(m),
|
|
}
|
|
if raw, ok := toolArgsFor(m); ok {
|
|
b.Args = red.JSON(raw)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// toolNameFor picks the tool label. Production messages don't carry a
|
|
// "tool_name" metadata key — service_messages.go writes the normalized
|
|
// *streams.NormalizedPayload under metadata["normalized"] and the tool kind
|
|
// ends up on Message.Type ("tool_read", "tool_execute", …). Strip the
|
|
// "tool_" prefix from Type to get a useful label ("read", "execute", "edit").
|
|
// The debug-fixture replay path *does* set metadata["tool_name"], so prefer
|
|
// that when present for backward compatibility.
|
|
func toolNameFor(m *models.Message) string {
|
|
if name := metaString(m.Metadata, "tool_name"); name != "" {
|
|
return name
|
|
}
|
|
return strings.TrimPrefix(string(m.Type), "tool_")
|
|
}
|
|
|
|
// toolArgsFor picks the args JSON payload. Production stores the typed tool
|
|
// payload under metadata["normalized"]; the debug-fixture replay path stores
|
|
// it under metadata["args"]. Prefer "args" so existing fixtures keep their
|
|
// shape, fall back to "normalized" so real shares have something to redact.
|
|
func toolArgsFor(m *models.Message) (json.RawMessage, bool) {
|
|
if raw, ok := metaJSON(m.Metadata, "args"); ok {
|
|
return raw, true
|
|
}
|
|
return metaJSON(m.Metadata, "normalized")
|
|
}
|
|
|
|
func metaString(m map[string]interface{}, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if v, ok := m[key]; ok {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// metaJSON marshals an arbitrary metadata value back to a JSON RawMessage so
|
|
// it can be redacted via Redactor.JSON. Returns ok=false if the key is missing.
|
|
func metaJSON(m map[string]interface{}, key string) (json.RawMessage, bool) {
|
|
if m == nil {
|
|
return nil, false
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return raw, true
|
|
}
|