mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(hooks): every mount gets its own hub link, so agents stop mixing projects up (#105)
A session whose root holds two connected folders got exactly one project's
base URL in its turn context: `bdrive sync --hook` emitted for the first
mount and stopped. Agents then hung the other project's paths on that URL,
producing links to project B carrying project A's path — confidently wrong,
and 404 on arrival.
The context now carries every mount as a `prefix → URL` pair, where the
prefix is the mount's path as the agent sees it from the session's folder.
A session started INSIDE a mount has no prefix to strip, so its own subpath
is baked into the base URL instead — the other half of the same bug, which
made every link from a subdirectory session miss its leading segments.
The formula also now states outright that the URL path is folder-relative
and that a path matching no listed folder is not synced and must not be
linked at all. Encoding is per-segment, `/` left literal (encodePathSegments).
Two more fell out along the way:
- `runHookSync` returns nil on every path by design ("a hook must never
fail the turn"), so the caller's `err == nil` guard could never be false.
A first mount that emitted nothing — non-hub remote, session open error —
still consumed the one emission, and NO mount got a link. It now reports
whether it produced a URL.
- stdin was read once per mount, so with several mounts only the first got
its session note stamped. The event JSON is read once per run now.
Single-mount output is unchanged apart from the folder-relative sentence.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1c703c95f8
commit
a84fe444f5
@@ -78,7 +78,13 @@ onboarding runbook every agent follows. `bdrive init` registers the hooks in
|
||||
each platform's USER config (`~/.claude/settings.json` and friends), once per
|
||||
machine: a blocking pull at UserPromptSubmit — which, via `bdrive sync
|
||||
--hook`, also injects the project's gated-link formula as additionalContext
|
||||
so agents append `path` [🔗](hub link) to every synced path they mention — an
|
||||
so agents append `path` [🔗](hub link) to every synced path they mention. One
|
||||
run can cover several mounts (`syncTargets`) and the hook's stdout contract is
|
||||
a single JSON object, so the formula carries **every** mount as a `prefix →
|
||||
URL` pair — the prefix being the mount's path as the agent sees it from the
|
||||
session's folder, or an empty prefix with the session's own subpath baked into
|
||||
the URL when the session runs inside the mount; emitting only the first mount
|
||||
hung one project's paths on another project's base URL. Then an
|
||||
async push on PostToolUse Write/Edit, and `bdrive read-log` on
|
||||
Read/Grep/Bash for the read heatmap. The inline hook commands
|
||||
`internal/agenthooks` writes must stay a fast no-op outside BearDrive folders
|
||||
|
||||
+9
-6
@@ -92,19 +92,22 @@ list in .bdrive/config.json is never pruned against either.`,
|
||||
|
||||
if hookLabel != "" {
|
||||
// Agent-hook mode: event JSON on stdin, silent best-effort
|
||||
// sync, link-formula context on stdout. Never fails. Only the
|
||||
// first mount emits context — the JSON contract is one object,
|
||||
// so a repo with several mounts links through the first.
|
||||
emit := true
|
||||
// sync, link-formula context on stdout. Never fails. Every
|
||||
// mount contributes its own prefix→URL pair; the JSON
|
||||
// contract is one object, so they are emitted together after
|
||||
// the loop.
|
||||
sessionID := hookSessionID(cmd)
|
||||
var links []hookLink
|
||||
for _, target := range targets {
|
||||
proj, ok, err := config.LoadProject(target)
|
||||
if err != nil || !ok || syncBlocked(proj) != "" {
|
||||
continue
|
||||
}
|
||||
if err := runHookSync(cmd, target, hookLabel, emit); err == nil && emit {
|
||||
emit = false
|
||||
if base, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
|
||||
links = append(links, hookLinkFor(folder, target, base))
|
||||
}
|
||||
}
|
||||
emitHookContext(cmd, links)
|
||||
return nil
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
|
||||
+100
-26
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -17,6 +19,12 @@ import (
|
||||
// project's gated-link formula as additionalContext, so the agent can
|
||||
// append a hub link to any synced file path it mentions.
|
||||
//
|
||||
// One run can cover several mounts (a repo root whose wiki/ and docs/ are
|
||||
// separate projects, see syncTargets), and the hook's stdout contract is a
|
||||
// single JSON object — so every mount's link goes into one context, keyed
|
||||
// by the path prefix an agent sees. Emitting only the first mount's URL
|
||||
// made agents hang one project's base URL on another project's paths.
|
||||
//
|
||||
// Everything is best-effort: a hook must never fail the turn, so every
|
||||
// error path is a silent, successful exit.
|
||||
|
||||
@@ -24,25 +32,35 @@ import (
|
||||
// scans keep stamping this session's changes for a while.
|
||||
const hookNoteTTL = 30 * time.Minute
|
||||
|
||||
// emitContext is false for every mount after the first in one hook run: the
|
||||
// hook's stdout contract is a single JSON object.
|
||||
func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) error {
|
||||
// The platform pipes its event JSON on stdin; the session id is all we
|
||||
// need from it here.
|
||||
// hookLink pairs the path prefix an agent writes with the hub URL that
|
||||
// prefix maps to.
|
||||
type hookLink struct {
|
||||
prefix string // "wiki/", or "" when the hook ran at or inside the mount
|
||||
base string // https://hub/<project-id>[/<the run folder's subpath>]
|
||||
}
|
||||
|
||||
// hookSessionID reads the platform's event JSON from stdin — once per run,
|
||||
// since stdin can only be consumed once and the sync loop may cover several
|
||||
// mounts.
|
||||
func hookSessionID(cmd *cobra.Command) string {
|
||||
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
||||
var event struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &event) // malformed input: just sync
|
||||
return event.SessionID
|
||||
}
|
||||
|
||||
sess, proj, err := openSession(cmd.Context(), folder, true)
|
||||
// runHookSync syncs one mount and reports its hub base URL, if it has one.
|
||||
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, bool) {
|
||||
sess, proj, err := openSession(cmd.Context(), target, true)
|
||||
if err != nil {
|
||||
return nil // not a mount / no session: fast no-op
|
||||
return "", false // not a mount / no session: fast no-op
|
||||
}
|
||||
defer closeSession(sess)
|
||||
|
||||
if event.SessionID != "" {
|
||||
note := label + " session " + event.SessionID
|
||||
if sessionID != "" {
|
||||
note := label + " session " + sessionID
|
||||
if err := sess.Store.SaveNote(note, hookNoteTTL); err == nil {
|
||||
sess.Note = note
|
||||
}
|
||||
@@ -51,34 +69,90 @@ func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) err
|
||||
// The pull. Offline is fine — the link formula below is still valid
|
||||
// for teammates who are online.
|
||||
if _, err := sess.Cycle(cmd.Context()); err != nil {
|
||||
return nil // never break the turn
|
||||
}
|
||||
|
||||
if !emitContext {
|
||||
return nil
|
||||
return "", false // never break the turn
|
||||
}
|
||||
server, projectID, err := splitHubRemote(proj.Remote)
|
||||
if err != nil {
|
||||
return nil // non-hub remote: nothing to link to
|
||||
return "", false // non-hub remote: nothing to link to
|
||||
}
|
||||
return server + "/" + projectID, true
|
||||
}
|
||||
|
||||
// hookLinkFor places one mount relative to the folder the hook ran in.
|
||||
// Agents write paths as they see them from that folder, so the mount's
|
||||
// position there is what turns a path into a URL: a mount BELOW the folder
|
||||
// contributes a prefix to strip, while a run INSIDE a mount contributes a
|
||||
// subpath that belongs in the base instead (there is no prefix to strip —
|
||||
// every path the agent writes is already inside the mount).
|
||||
func hookLinkFor(folder, target, base string) hookLink {
|
||||
// resolvePath: registry paths and the run folder can name the same
|
||||
// directory through different symlinks (macOS /tmp).
|
||||
rel, err := filepath.Rel(resolvePath(folder), resolvePath(target))
|
||||
if err != nil {
|
||||
return hookLink{base: base}
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
switch {
|
||||
case rel == ".":
|
||||
return hookLink{base: base}
|
||||
case rel == ".." || strings.HasPrefix(rel, "../"):
|
||||
sub, err := filepath.Rel(resolvePath(target), resolvePath(folder))
|
||||
if err != nil {
|
||||
return hookLink{base: base}
|
||||
}
|
||||
return hookLink{base: base + "/" + encodePathSegments(filepath.ToSlash(sub))}
|
||||
default:
|
||||
return hookLink{prefix: rel + "/", base: base}
|
||||
}
|
||||
}
|
||||
|
||||
// emitHookContext writes the turn's additionalContext — one JSON object, no
|
||||
// matter how many mounts the run covered.
|
||||
func emitHookContext(cmd *cobra.Command, links []hookLink) {
|
||||
if len(links) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Shared tail: what the links mean and when NOT to use them.
|
||||
const tail = "These links require hub sign-in + project membership, so they are safe to paste anywhere internal. " +
|
||||
"Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. " +
|
||||
"`bdrive share <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link."
|
||||
|
||||
var context string
|
||||
if len(links) == 1 && links[0].prefix == "" {
|
||||
// The common case: one project, paths already relative to its root.
|
||||
// Kept as short as possible — this is paid on every turn.
|
||||
b := links[0].base
|
||||
context = fmt.Sprintf(
|
||||
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
|
||||
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — the path stays plain text, the hyperlink goes on the emoji only. "+
|
||||
"The URL path is the file's path relative to this folder, with each segment percent-encoded and the `/` separators left literal. "+
|
||||
tail, b, b, b)
|
||||
} else {
|
||||
parts := make([]string, len(links))
|
||||
for i, l := range links {
|
||||
p := l.prefix
|
||||
if p == "" {
|
||||
p = "./"
|
||||
}
|
||||
parts[i] = fmt.Sprintf("`%s` → %s", p, l.base)
|
||||
}
|
||||
context = fmt.Sprintf(
|
||||
"beardrive: hub URLs for the synced folders here — %s. "+
|
||||
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](<that folder's URL>/<url-encoded path within it>) — the path stays plain text, the hyperlink goes on the emoji only. "+
|
||||
"Pick the folder whose prefix above matches the path longest, strip that prefix, then percent-encode each remaining segment and leave the `/` separators literal. A path matching none of these folders is not synced — do not link it, and never hang one folder's path on another folder's URL. "+
|
||||
tail, strings.Join(parts, ", "))
|
||||
}
|
||||
base := server + "/" + projectID
|
||||
|
||||
out := map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": fmt.Sprintf(
|
||||
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
|
||||
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — the path stays plain text, the hyperlink goes on the emoji only. "+
|
||||
"These links require hub sign-in + project membership, so they are safe to paste anywhere internal. "+
|
||||
"Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. "+
|
||||
"`bdrive share <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link.",
|
||||
base, base, base),
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context,
|
||||
},
|
||||
}
|
||||
enc, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -150,6 +151,124 @@ func TestSyncHookModeNoOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mountAt creates a project folder under parent and enrolls it on this
|
||||
// device, as `bdrive init` would.
|
||||
func mountAt(t *testing.T, parent, name, remote string) config.Project {
|
||||
t.Helper()
|
||||
dir := filepath.Join(parent, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := config.SaveProject(dir, config.Project{Volume: name, Remote: remote})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := config.ResolveMount(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return proj
|
||||
}
|
||||
|
||||
func runHook(t *testing.T, folder string) string {
|
||||
t.Helper()
|
||||
c := syncCmd()
|
||||
var out bytes.Buffer
|
||||
c.SetOut(&out)
|
||||
c.SetIn(strings.NewReader(`{"session_id":"sess-42"}`))
|
||||
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatalf("hook mode must never fail: %v", err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// A session at a root whose subfolders are separate projects must get EVERY
|
||||
// project's URL, each keyed by the prefix the agent sees — emitting only the
|
||||
// first mount's base made agents hang one project's paths on another
|
||||
// project's URL.
|
||||
func TestSyncHookModeMultipleMounts(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
|
||||
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
|
||||
|
||||
got := runHook(t, root)
|
||||
|
||||
// One JSON object: the hook's stdout contract.
|
||||
if n := strings.Count(strings.TrimSpace(got), "\n"); n != 0 {
|
||||
t.Fatalf("hook emitted %d objects, want 1:\n%s", n+1, got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"https://hub.example.com/p-aaaaaaaa",
|
||||
"https://hub.example.com/p-bbbbbbbb",
|
||||
"`projA/`",
|
||||
"`projB/`",
|
||||
"matches the path longest", // how to pick between them
|
||||
"do not link it", // a path in neither is not synced
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("hook output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// stdin is consumed once, so the note must still reach every mount.
|
||||
for _, proj := range []config.Project{a, b} {
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(vdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if note := st.LoadNote(); note != "claude-code session sess-42" {
|
||||
t.Errorf("%s: note = %q, want the stamped session", proj.Volume, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mount that has no hub URL must not swallow the context for the mounts
|
||||
// that do — the old "first mount emits" guard could never detect a mount
|
||||
// that emitted nothing, because hook mode never returns an error.
|
||||
func TestSyncHookModeSkipsNonHubMount(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
mountAt(t, root, "a-plain", "file://"+t.TempDir()) // sorts first, no hub
|
||||
mountAt(t, root, "b-hub", "https://hub.example.com/p/p-bbbbbbbb")
|
||||
|
||||
got := runHook(t, root)
|
||||
if !strings.Contains(got, "https://hub.example.com/p-bbbbbbbb") {
|
||||
t.Errorf("hub mount lost its link behind a non-hub mount:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "`b-hub/`") {
|
||||
t.Errorf("hub mount missing its prefix:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "a-plain") {
|
||||
t.Errorf("non-hub mount has no URL and must not be listed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A session started inside a mount writes paths relative to its own
|
||||
// directory, so that subpath belongs in the base URL — there is no prefix
|
||||
// for the agent to strip.
|
||||
func TestSyncHookModeInsideMount(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
||||
sub := filepath.Join(root, "wiki", "docs", "notes")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := runHook(t, sub)
|
||||
if !strings.Contains(got, "https://hub.example.com/p-12345678/docs/notes") {
|
||||
t.Errorf("base URL missing the session's subpath (and `/` must stay literal):\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Plain `bdrive sync` (the push hook's form, and what users type) refuses
|
||||
// unenrolled and paused mounts with instructions instead of silently
|
||||
// enrolling or resuming.
|
||||
|
||||
@@ -31,7 +31,9 @@ With no argument, `bdrive url` gives the project home.
|
||||
:::tip[Agents do this automatically]
|
||||
The sync hook `bdrive init` registers injects the project's
|
||||
gated-link formula into the agent's context, so a connected agent appends
|
||||
`path` [🔗](link) to every synced path it mentions — without being asked. See
|
||||
`path` [🔗](link) to every synced path it mentions — without being asked. If a
|
||||
session's root holds several connected folders, each one's URL goes in, so a
|
||||
path is always linked to the project it actually lives in. See
|
||||
[Set up with your agent](/start/setup/).
|
||||
:::
|
||||
|
||||
|
||||
Reference in New Issue
Block a user