Conversation
The sync hook teaches every agent to append a hub link to every synced path it mentions. Fetching one returned 200 OK, text/html, an empty SPA shell — byte-identical to "no such file", "no such project" and "not permitted", so an agent summarized nothing and reported that it had read the doc. Server.frontend now negotiates: a request whose Accept names no text/html on /<project-id>/<path> gets the file's own bytes, content type and an X-Bdrive-Provenance header instead of the shell. Same URL — a browser's answer is byte-identical, view routes stay pages for every Accept, and the deliberate root-dotted-path 404 (/llms.txt) is untouched. The SPA fallback runs OUTSIDE authGate (auth.go treats every non-/api/ path as open so a browser can reach the login page), so the branch authenticates itself, and does so BEFORE resolving anything: the 404 names a project, which for an anonymous caller would be an existence oracle. Order is negotiate -> 401 -> serveProject (404, then 403) -> serve. Three seams made that reuse possible, none of them behavior changes: proj() lifted out of Handler()'s closure into a serveProject method, lookup split so the path can come from the URL, and recordRead given an agent kind. The agent actor is an ownsDevice-validated device id or the fixed string "agent" — never an email, which /heat?by=device publishes to every project member. Rebased onto main past BEA-181's unfurl titles: known link-unfurler User-Agents (Slackbot, Discordbot, facebookexternalhit, ...) keep the titled shell, since several send no text/html and would otherwise unfurl as a 401. The agent door also inherits folder visibility (hidden = 404). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Accepthas notext/html(agents,curl) gets the file, plus anX-Bdrive-Provenanceheader.Closes BEA-186.
The one thing to check: the anonymous read
The SPA fallback runs outside
authGate(auth.goleaves every non-/api/path open so a browser can reach the login page). So the new branch has to authenticate itself, and the order matters:flowchart LR A["GET /<pid>/<path>"] --> B{"Accept has text/html?<br/>view route? unfurler UA?<br/>project root?"} B -- yes --> Shell["app shell, unchanged"] B -- no --> C{"Authenticate"} C -- fail --> U401["401"] C -- ok --> D["serveProject(PermRead)"] D -- "no such project" --> N404["404"] D -- "not permitted" --> F403["403"] D -- ok --> E["serveFileAt<br/>(folder visibility -> 404)"] E --> OK["200 file + X-Bdrive-Provenance<br/>read recorded as agent"]Authentication comes before
serveProject. Otherwise itsno such project404 would tell an anonymous caller which project ids exist.TestAgentFetch_NegotiatedFileOnTheHumanURLcovers the unauthenticated case first: 401, and no file bytes anywhere in the body.What changed
Three seams, none of which change behavior on their own:
proj()closure became theserveProjectmethodserver.goHandler()and has nor.PathValue("project")lookupsplit intolookup+lookupPathserver.go?path=(folder visibility is still applied)recordReadlearned the agent kindreads.goownsDevice-validated device id, or the fixed string"agent". It is never an email, because/heat?by=deviceshows agent actors to every memberThe branch itself is
agentFetchTarget+serveAgentFetchat the top ofServer.frontend, above the shell's headers. A file served there carries exactly the headers/api/p/<id>/filedoes: ETag, sandbox CSP,nosniff, canonical-move header.X-Bdrive-Provenancecomes from theFileInfoalready in hand (the newest journal op), so it costs no history query. Every value goes throughstrconv.Quote, becauseAuthor/Userare client-written journal fields and a raw CR/LF would be response splitting.Unchanged: view routes (
/<pid>/history,dashboard,install,settings, legacyinsights, whichreservedViewsmirrors fromrouter.ts), project roots, single-volume mode,/s/<token>(out of scope per the spec), and the deliberate root-dotted-path 404 (/llms.txt).Deviations from the plan
<title>/og:*tags for unfurlers. Several unfurlers send notext/html, so without an exemption a pasted hub link in Slack would unfurl as a 401.unfurlerUAs(Slackbot, Discordbot, facebookexternalhit, Twitterbot, LinkedInBot, …) keeps those on the shell.TestShellOpenGraphnow sends a Slackbot UA, which is the caller that test actually describes.lookupPath.TestAgentFetch_HiddenFolderIsNotFoundpins that.…-with-2. An earlier build run left an unpushed commit on the original branch name (in another worktree). This branch cherry-picks that work onto current main and resolves conflicts with BEA-181, the Mac-appReportReadhook and folder permissions. The agent branch sits aboveReportRead, because the sidecar forwards whatever it gets as a human read.TestFrontendRootDottedPathsAre404,TestSec_Router_TheShellIsServedForPathsTheClientMustSurvive,seccfgRaw) now send a browserAccept. They assert the shell a browser navigation gets. With noAcceptat all, those requests now count as agent fetches.What was run
go test ./...: all green. New:internal/webapp/agentfetch_test.go(6 tests covering 401 without credentials and no bytes in the body, 403 for another org, 404 for a missing file/project and a hidden folder, 200 + provenance, a byte-safe browser shell, view routes,/llms.txtstill 404, agent vs human heat, device-id actor,/store/*recording no read, and the unfurler exemption).npm run e2e: 236 passed, 7 failed, 1 skipped. The 7 are the admin/hub org specs (admin.spec.ts15/42/202/230/246,hub.spec.ts63/90), and a run onorigin/mainat 9b3eb19 fails the same 7. They're a known load-sensitive flake on main:admin.spec.ts:15times out waiting for the account dropdown, leaves the org renamed, and the other six cascade from that. They don't touch the agent-fetch path (/orgs/*,/api/orgs). Caveat: this is 2 branch runs vs 1 main run, and the Go suite was running alongside some of them, which is the condition that makes this flake likely. Worth re-running on an idle machine before merge.go vet ./...is clean, and both mermaid blocks inarchitecture/webapp-server.mdrender withmmdc.TestAgentFetch_BrowserAnswerIsUntouchedplus the green SPA e2e specs cover that.No frontend source changed, so there is no
static/rebuild and no screenshots. The change is a response body, not a UI.Architecture changes
architecture/webapp-server.md:serveProject(the liftedprojclosure) and theagentFetchbranch ofServer.frontendare new.agentFetchnow reachesAuthProvider(401 first),serveProject(404, then 403) andReadLedger(agent kind). TheprojectPermnote now namesserveProjectas the choke point.flowchart TB Server["Server"] AuthProvider["AuthProvider"] ReadLedger["ReadLedger"] projectPerm["<div style='text-align:left'><b>projectPerm</b><br/>org owner → admin<br/>explicit grant<br/>org member → project Default<br/>otherwise → none</div>"] serveProject["<div style='text-align:left'><b>serveProject</b><br/>projectVolume(id) → 404<br/>requirePermOn(level) → 403<br/>withProjectID(r, id)<br/>h(volume, w, r)</div>"] agentFetch["<div style='text-align:left'><b>agentFetch</b> (Server.frontend branch)<br/>Accept has no text/html<br/><project-id>/<path>, rest non-empty<br/>head not in reservedViews<br/>UA not in unfurlerUAs<br/>Auth.Authenticate → 401<br/>serveProject(PermRead)<br/>serveFileAt + X-Bdrive-Provenance<br/>withAgentFetch(r) → agent-kind read</div>"] PermNote["perms.go — the one choke point:<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>proj(level, h) closure in Handler()</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ serveProject(w, r, id, level, h) method,<br/>shared by routes and the SPA fallback</span>"] Server -- "gates every per-project route" --> projectPerm Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ one per-project resolver</span>" --> serveProject serveProject -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ requirePermOn</span>" .-> projectPerm Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Server.frontend, above the shell</span>" --> agentFetch agentFetch -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Authenticate → 401 first</span>" .-> AuthProvider agentFetch -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ 404 then 403, then the file</span>" .-> serveProject agentFetch -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ ReadKindAgent, never an email</span>" .-> ReadLedger projectPerm -.- PermNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class serveProject,agentFetch added class PermNote noteBox linkStyle 1 stroke:#22c55e,stroke-width:2px linkStyle 2 stroke:#22c55e,stroke-width:2px linkStyle 3 stroke:#22c55e,stroke-width:2px linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2px linkStyle 6 stroke:#22c55e,stroke-width:2pxBuild session
(only works on the build machine)
🤖 Generated with Claude Code