Release: unified context model + bot-to-bot dispatch & governance + Fleet (develop → main)#212
Conversation
First deliverable of the multi-agent governance track (docs/design/FLEET_VIEW.md): one surface answering "who is waiting on me?" and "what is my fleet doing?". Gateway: - GET /workspaces/:id/fleet — pending permission cards across the caller's channels (SEE-gated, flagged actionable via the same 3-way compose as resolve_permission) + bot roster with liveness, busy/idle session counts, status line, cost-today rollup - Aggregation fails CLOSED on policy errors (unlike the in-channel fanout's deliberate fail-open) — a DB hiccup must not widen visibility - Workspace access accepts owner_user_id (personal workspaces have no membership rows) Frontend: - /fleet page: approvals inbox (reuses PermissionCard with a new server-authoritative approverOverride prop) + per-channel bot roster with presence dot, status chip, session counts, cost today - Radar rail button; 30s polling + focus refetch (live WS chips arrive with P2 bot_processing) Verified end-to-end on the kind stack: roster renders real bots per channel, connector liveness flips the presence dot/chip live, approvals zone empty-states correctly; cargo test 131 passed, frontend build + typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the Fleet view live instead of poll-only:
- Gateway now emits the previously-dead `bot_processing` WireFrame on
task dispatch (the frontend handler already existed but no site emitted
it). Its "idle" counterpart is the turn's message_done — clients pair
the two.
- GET /fleet/badge: workspace-agnostic count of approvals the caller may
answer (same 3-way compose, factored into a shared see_and_answer
helper reused by get_fleet). Feeds a rail badge on the Radar button.
- FleetPage subscribes to its channels over one WS (useFleetLive) and
does a debounced refetch on bot_processing / message_done / message /
presence frames; the 30s poll stays as fallback.
Verified on the kind stack: dispatching a bot logs "task dispatched"
(the bot_processing broadcast is unconditional right before it) and the
bot's session flips to `busy` in the DB — the data the "working" chip
reads; /fleet/badge returns {"count":N} 200; cargo build + 131 tests
pass; frontend build + typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ROADMAP (+zh): Fleet follow-ups — bot-to-bot dispatch (in progress), Fleet P3 (recorded) - New docs/design/BOT_DISPATCH.md: Dispatch capability + bot-as-subject grant, default-allow, fail-closed gate, per-decision audit; grounded in the existing chains.rs gate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gateway hardening of bot@bot triggering into a first-class, auditable, fail-closed governance gate (docs/design/BOT_DISPATCH.md). - New Capability::Dispatch + subject_kind='bot': the initiating bot is a first-class grant subject, so 'which bots may command bot B' is authored and evaluated in the same user▸group▸role▸* matrix, distinct from 'which humans may prompt B'. resolve_dispatch precedence: (chan,bot:A)▸(chan,bot:*)▸(bot-wide,bot:A)▸(bot-wide,bot:*)▸default-allow. - chains.rs gate rewired from the INITIATE·prompt overloading hack (role='bot' / user=<botid>) to resolve_dispatch_decision, which is FAIL-CLOSED: a policy-store error denies (was fail-open), while 'no rule' still allows (backward compatible). - Every decision (allow AND deny) writes an acp_event_log row (name='dispatch', home='cheers') naming initiator/target/decision/reason — the permanent trail that makes this a moat, not just a filter. - Migration 0046: relax capability + subject_kind CHECKs; one-shot migrate any legacy role='bot'/user=<botid> INITIATE·prompt rows to bot subjects. - Unit tests: default-allow, deny-wins over wildcard, channel-scope beats bot-wide, and human INITIATE rules don't leak into dispatch. 135 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…context Supersedes the narrow handoff idea: one primitive, two axes (producer: human/bot × pick mode: manual/auto). context bundle = refs to existing resource verbs; delivered on the message + task frame; resolved by the consumer's own Principal (consumer-governed reads). Hard rule: auto-pick is never silent (visible + removable / carded + audited). Decisions locked: JSONB column on messages; no preview for restricted refs; F1 kinds = plan + file + message. Phases F0→F1→F2→F3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Not a conflict (different scope + slot); pin validates the direction. Three rules: no name collision (picker = 'add context', not 'pin'; pinned files shown as already-present), no double delivery (picker disables pinned files, handoff auto-assembler excludes pinned paths), future unification (pin as a channel-scoped standing bundle) out of v1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for resource-context (docs/design/RESOURCE_CONTEXT.md): a message may carry a context_bundle (refs to Cheers resources), delivered to any triggered bot's task frame so the agent resolves the refs itself. - Migration 0047: context_bundle JSONB column on messages. - Bridge Task frame: new optional context_bundle field, distinct from the channel-standing 'pinned' slot (skip_serializing_if=None → golden fixtures unchanged; connector matches use '..' so they're unaffected). - send_message / create_message accept + persist it; dispatcher's load_task_context reads it off the trigger row into TaskContext and build_task_frame threads it onto the frame. Delivery only (F0); the human picker (F1) and bot handoff (F2) that produce bundles, and the de-dup vs pinned files, come next. gateway 135 + connector 91 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composer 'Add context' bar (docs/design/RESOURCE_CONTEXT.md): attach Cheers resources to a message; sent as a context_bundle to any triggered bot; rendered as chips on the message. - contextPick store (zustand): pending picks per channel; two entry points (composer menu now; in-panel Viewboard/Workbench attach next) push here. - ContextPickBar: removable chips + 'Add context' menu (v1: Plan, Recent decisions — channel-scoped Viewboard resources, one click). - ChannelView.handleSend reads the store, ships context_bundle, clears on success. MessageContextChips renders a sent message's bundle. - Backend read-path: context_bundle on MessageDto + list SELECT + live 'message' frame, so chips survive reload. - Doc: pickable = Viewboard/Workbench content; two entry points (composer picker + in-panel attach, the pageagent pattern). Verified in the browser: 'Add context' → Plan → removable 'Plan' chip renders. Found + fixed a zustand getSnapshot infinite-loop (selector returned a fresh [] each render → stable EMPTY ref). tsc clean; gateway 135 + connector 91 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pageagent pattern: attach what you're viewing, without leaving it. - AttachContextButton: reusable '+ attach to next message' control (shows a check once added), pushing to the same contextPick store. - ViewBoard header gains a '+' that attaches the active board as a resource ref (plan/cost/sessions/activity → their read verbs; audit is REST-only so it's excluded). Session-scoped boards carry the selected session_id. - Workbench FilePanel gains a '+' beside the pin toggle attaching the open file (fs.read by path) — DISABLED when the file is already pinned, so a pinned file never rides both slots (the pin de-dup rule). Verified in the browser: opening the ViewBoard Plan tab and clicking '+' adds a 'Plan' chip to the composer. tsc + vite build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a bot@bot dispatch passes the gate, the gateway now hands the target the initiator's working state instead of leaving it to guess from chat history (docs/design/RESOURCE_CONTEXT.md, F2). - chains.rs::assemble_handoff_bundle builds a context_bundle of refs to the initiator's plan (channel.plan.read, scoped to its primary session) and the channel's recent decisions (channel.activity.read), assembled once per trigger (same initiator for all targets). - DispatchParams gains context_bundle; when set (bot@bot) it overrides the trigger message's bundle on the target's task frame. Human path passes None so a human-attached (F1) bundle still flows through. - Refs only, consumer-governed: the target resolves them as itself, so the handoff can't deliver anything the target isn't already allowed to read. Follow-ups: files-touched refs, and a human-facing handoff card on the placeholder message. gateway build + 135 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The F2 handoff bundle now rides the target bot's placeholder message (not only its task frame), so a bot@bot handoff is visible to humans, not just delivered to the agent — satisfying the 'auto-pick is never silent' rule. - create_placeholder persists the bundle on the bot's message row; the live placeholder frame + the message_done frame both carry it (RETURNING context_bundle), so the card shows on the thinking bubble and survives finalize without a refetch; the DTO round-trip covers reload. - MessageContextChips renders origin='handoff' as a labeled '↪ Received handoff' card (indigo tint) vs a human pick's plain chips. gateway build + frontend tsc/build clean. Live render batches with the other deploy-gated checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuilds recompiled the entire ~500-crate dependency tree on every source change because the Dockerfile COPYed src before a single cargo build (no dep-caching layer). This adds the standard stub-source layer: build deps once from Cargo.toml/Cargo.lock, then COPY real sources so only the two workspace crates recompile — cutting redeploys from ~50min to ~1-2min.⚠️ UNVERIFIED: could not validate the build because Docker Desktop's VM networking is down (host reaches Docker Hub, but 'docker pull' hangs on the base image). DO NOT MERGE until a successful build confirms the stub-source layer is correct. Re-run: docker build -t cheers/gateway:dev -f server/Dockerfile . once Docker networking recovers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nary
Without this, the stub-source dep-cache layer shipped an EMPTY binary:
docker COPY preserves the source files' original (git) mtimes, which are
OLDER than the stub artifacts built in the cache layer → cargo saw
'source older than artifact', SKIPPED the rebuild (step-2 'cargo build'
finished in 0.20s), and the runtime got 'fn main(){}' → exits 0 instantly
→ CrashLoopBackOff. Touch the real .rs files before the step-2 build so
their mtime beats the stubs.
VALIDATED on the kind stack: full first build then a 22s incremental
rebuild (dep layer CACHED, only server recompiled, 20s), producing a
working gateway that boots + applies migrations 46/47. Un-drafting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integration tests construct CreateMessageParams/DispatchParams directly; F0/F2 added the context_bundle field to both structs but the flows.rs test fixtures weren't updated, breaking the Gateway Integration (Postgres) CI job with E0063 missing-field errors. Pass None everywhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The standard Rust connector deserialized the task frame's context_bundle but then dropped it: handle_control destructured ControlInbound::Task with `..`, TaskCommand had no matching field, and build_prompt never consumed it — so any human-picked or handed-off bundle reached only the raw WS frame and was never exposed to the agent (F0/F1/F2 delivery was a no-op for the supported connector). Thread context_bundle through TaskCommand → handle_control → build_prompt, and render it as a reference block: each item's label/kind + resource verb + params, with a header that distinguishes a human pick from a bot hand-off (naming the initiator). These stay references the agent resolves on demand via its Cheers resource tools — not inlined like pinned blocks — matching docs/design/RESOURCE_CONTEXT.md. Adds 2 prompt tests. Reported by codex review on #198. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generic PUT/DELETE /bots/:id/event-access endpoints parse capability
via Capability::parse, which now accepts 'dispatch'. But these endpoints
only allow subject_kind role|user|group, while the dispatch resolver only
honors subject_kind='bot'. So a client could save a role/user/group
'dispatch' deny and get {ok:true}, yet the rule is silently ignored (bot@bot
dispatch falls back to allow); conversely the only effective bot-subject
dispatch rule can't be created through this endpoint at all.
Reject Capability::Dispatch in the API-level parse_capability so the human
INITIATE/SEE/RESPOND matrix endpoints stay honest (matching their own error
text and subject_kind validation). Capability::parse is left intact for the
resolver/loader. Dispatch rules get a dedicated management path in D2
(docs/design/BOT_DISPATCH.md). Adds 3 guard tests.
Reported by codex review on #197.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
is_workspace_member treated any workspace_memberships row as access, but an invite creates a status='pending' row that grants nothing until accepted (migration 0025). A pending invitee could call /workspaces/:id/fleet and receive the bot roster + pending approvals for channels they still have rows for. Gate the membership EXISTS on status='active', matching the rest of the workspace reads (api/workspaces.rs). The owner_user_id path (personal workspaces, no membership row) is unchanged. Adds an integration test covering pending → active → owner. Reported by codex review on #196. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat: resource context (design + F0 foundation)
feat: bot-to-bot dispatch under the grant matrix (D1, gateway)
feat: Fleet view — agent-fleet mission control (P1 + P2)
Symmetric mirror of F1's human picker: a bot can attach a context bundle to
a message it posts, instead of only the gateway inferring one on handoff (F2).
This is the pageagent case — an agent picks up Cheers resources and hands them
to another agent so the receiver reads the same state instead of guessing.
- MCP: post_message gains an optional `context` array of resource refs; the
arm wraps it as {items} into the existing channel.messages.create params.
No connector/bridge-protocol change (params forwarded verbatim).
- Gateway: resource::messages::handle_create reads context_bundle, normalizes
via the new domain::context_bundle::sanitize_bot_bundle (read-verb allowlist,
item cap, origin stamped 'bot' so a bot can't spoof handoff/human), persists
it, and returns it on the DTO (was hardcoded None).
- Bot->bot: chains.rs::assemble_handoff_bundle now merges the triggering
message's manually-picked items AHEAD of the auto plan+decisions, so a
mentioned target's task frame carries both.
- Delivery reuses F0/F1: the DTO broadcast carries the bundle (chips render,
zero FE change); the connector's context_bundle_block renders it for the
receiving agent. Reads stay consumer-governed.
Tests: 5 validator unit tests, 3 MCP arm tests, 1 #[sqlx::test] integration
(read verb kept, write verb dropped, origin stamped, persisted; all-dropped =>
NULL). Design doc: RESOURCE_CONTEXT.md gains Producer D / F4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
perf(build): Dockerfile dependency-cache layer (redeploy ~50min→~2min)
feat(context): F4 — bot manual pick (post_message context bundle)
# Conflicts: # server/tests/flows.rs
feat(context): connector renders context bundles + F4 bot manual pick
Fills the last cell of the resource-context 2x2 (human / automatic pick). As a human composes a reply, the composer suggests the replied-to message as a dashed 'ghost' chip; one click promotes it to an ordinary F1 pick, one click dismisses it (session-local). Per the hard rule, suggestions are visible + droppable, NEVER auto-committed. - contextPick.ts: useContextSuggestions(channelId, replyTo) computes the reply-target suggestion (channel.messages.by-seq on its seq), minus anything already picked or dismissed; dismissSuggestion() + dismissed state. - ContextPickBar: renders suggestions as dashed ghost chips with + (promote) and x (dismiss), distinct from solid picks; accepts a replyTo prop. - ChannelView: passes its existing replyTo into ContextPickBar. - The mechanism is signal-agnostic — filename-in-draft and active-plan signals plug into the same hook later. Verified live in the browser (Vite vs kind gateway): reply -> ghost chip appears; + promotes to a solid pick (de-dups the suggestion); removing the pick brings the suggestion back; x dismisses it and it stays gone; no console errors. Design doc: RESOURCE_CONTEXT.md Producer C / F3 marked shipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ext-f3-suggested # Conflicts: # docs/design/RESOURCE_CONTEXT.md
…ested feat(context): F3 — suggested context (reply-target ghost chips)
Extends F3 suggested context beyond the reply target with two more draft signals (docs/design/RESOURCE_CONTEXT.md Producer C): - filename: when the draft names a file present in the channel (index built from loaded messages' attachments — no extra fetch), suggest that file. - plan: when the draft mentions "plan" (word-boundary), suggest the plan. MessageComposer gains onTextChange so the parent sees the live draft; ChannelView derives a channel file index + passes draft/files to the picker. Suggestions stay one-click add / one-click dismiss, never auto-committed; de-duped, picked/dismissed-filtered, capped at 4. Extracted the pure core (computeSuggestions) with 7 vitest cases; live-verified in the UI (typing "update the plan" surfaces a ghost Plan chip that promotes on click). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the context picker to attach just a *passage* of a Workbench (desk)
file, not only the whole file. docs/design/RESOURCE_CONTEXT.md Producer A.
- Backend: fs.read gains optional start_line/end_line (1-indexed inclusive);
returns that slice + the clamped range. Missing/out-of-order/out-of-bounds
bounds clamp to the file (never error). Whole-file read unchanged when absent.
New slice_lines helper (5 unit tests) + a #[sqlx::test] on real Postgres.
- Frontend: FilePanel gains an 'attach selection' button — select text in the
CodeMirror viewer, click, and the selection maps to a line range attached as
a scoped fs.read ref ('file.md:12-40'). Pure helpers selectionLineRange +
rangedFileContextItem (7 vitest cases).
- Live-verified: selecting lines 2-4 of a desk file produced the exact ranged
chip 'passage-demo.md:2-4' in the composer (CodeMirror getSelection→content
mapping confirmed in the real DOM).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A bot's remote workspace file is its live private machine, not a shared Cheers
resource, so it can't ride as a consumer-resolvable ref. Attach it as an inline
SNAPSHOT (content at pick time, capped) + a LOCATOR (workspace.file with
bot_id/path) so the receiver reads the snapshot and can ask the owning bot via
post_message for the live version. docs/design/RESOURCE_CONTEXT.md Producer A.
- contextPick.ts: ContextItem/ContextBundle gain optional preview:{text};
toBundle passes it through; new workspaceContextItem() captures a truncated
snapshot + bot-scoped locator params.
- RemoteWorkspaceDialog: 'Attach' button beside Download (text files) → adds the
workspace context item for the open file (current buffer + owner/path/session).
- Connector context_bundle_block: inlines the snapshot and, for workspace.file
refs, notes the original lives in the owner bot's workspace (ask via
post_message). New PREVIEW_MAX_CHARS cap mirrors the frontend.
Tests: +4 vitest (workspaceContextItem, toBundle preview) = 15 in the file;
+1 connector test (snapshot + locator render) = 94. Typecheck + builds clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Security review found pick-up/handoff could bypass the existing permission system. Fixes, keeping every path dependent on the original grants: A. sanitize_human_bundle (domain/context_bundle.rs), applied in send_message: stamp origin='human' (drop client origin/from -> no handoff spoof), allowlist READ_VERBS + workspace.file only (drop write verbs -> no confused-deputy fs.rm/fs.write injection), cap item/label/params/preview sizes. Each workspace.file snapshot re-verifies the caller's workspace/read on the owning bot (resolve_can_read, now pub(crate)) — a snapshot you can't read never rides. B. Snapshot never broadcast (domain/messages.rs): create_message persists a preview-stripped row (members + history carry label/locator only — the UI never rendered preview anyway) and passes the FULL bundle to DispatchParams so only the @mentioned target bot's task frame gets the snapshot. C. Handoff per-target re-auth + de-dup (domain/chains.rs): each handoff item is re-authorized against the TARGET via authorize_channel_read(Principal::bot), dropping refs it can't read; items de-duped by (verb,params) so a manual plan pick + the auto plan aren't delivered twice. D. Consumer hardening: connector context_bundle_block neutralizes newlines/ backticks in label/params, defuses fences in snapshots, caps items, and wraps the block in an explicit untrusted-data boundary. FilePanel passage-attach button is pin-gated (no double delivery). Bots can't produce previews (sanitize_bot_bundle strips them), so the only snapshot producer is the human path — no bot->bot workspace primitive needed. Tests: +5 context_bundle unit, +1 chains dedup, +2 connector prompt, +2 sqlx::test integration (row strips preview; handoff drops cross-channel ref). Live-verified on kind: a crafted POST with origin:handoff + fs.rm + a preview comes back origin=human, no fs.rm, no preview. Reported by the security review of the resource-context pipeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold every textual prompt part into one injection-safe XML envelope, replacing the ad-hoc parts.join and the prose BEGIN/END delimiter. docs/design/RESOURCE_CONTEXT.md. - build_prompt emits a single <context> text block (media stay separate) with typed children: <output_contract>, <identity>, <pinned>, <trigger from is_bot>, and <attached_context origin from> wrapping <reference verb kind params>label, legacy <snapshot>, and <attachment> summaries. - New xml_attr / xml_body escapers (entity-encode & < > and, for attributes, ") applied to EVERY interpolated untrusted field — including the previously un-neutralized trigger text, pinned blocks, and snapshot body. A hostile label / param / snapshot can no longer emit a real </context> or <system> tag. - Sender attribution moves to the trigger's from attribute; the bot-callback convention (post_message mention_names) is preserved for bot triggers with the interpolated name escaped. - Snapshot no longer needs the ``` fence defuse (XML tags delimit it now). Prompt text is not in the bridge-protocol golden fixtures, so this is a unit-test-only change: updated the reference/injection/trigger asserts to the XML shape; the new escaping test feeds </attached_context></context><system>… and asserts it is entity-escaped and only the envelope's own </context> closes. 95 connector tests green (golden fixtures included). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pickup and handoff are the same primitive — unify their DELIVER-time authorization behind one step (docs/design/RESOURCE_CONTEXT.md). - New context_bundle::finalize_bundle_for_target(db, bundle, target, channel): per-item authorize_channel_read against the TARGET (params.channel_id, fallback = dispatch channel) then dedup by (verb,params). Generalizes the handoff-only authorize_handoff_for_target + folds in the dedup that lived at assemble-time. - Human path (messages.rs): the dispatch loop had NO per-target authz — it cloned the full bundle to every mentioned bot. Now each target gets the finalized bundle (a ref it can't read is dropped), matching the handoff path. - Handoff path (chains.rs): replaces authorize_handoff_for_target with the shared finalizer; assemble_handoff_bundle no longer dedups (the finalizer does, per target). Orthogonal layers, all kept: produce-time sanitize_* (per-producer trust), deliver-time finalize (per-consumer), pull-time re-auth (final gate). Tests: dedup unit moved to context_bundle; integration renamed to finalize_drops_refs_target_cannot_read_and_dedups (drops cross-channel ref + collapses dup). 154 gateway unit green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…feat/context-finalizer
Foundation for workspace files as consumer-governed references (P3): a bot may read another bot's remote workspace only under its OWN workspace/read grant. - EV_WORKSPACE_READ event class; resolve_bot_workspace_read_opt mirrors the dispatch bot-subject precedence ((chan,bot:R)▸(chan,bot:*)▸(bot,bot:R)▸(bot,bot:*)) over the workspace_read class (Capability::Initiate). - resolve_bot_workspace_read applies the member-allow default (workspace/read isn't in OWNER_DEFAULT_INITIATE); owner denies a reader via a rule. - can_bot_read_workspace(db, owner, reader, channel): fail-closed on policy load error. The broker read (P3-2) calls this + confirms reader membership before reusing the workspace_call transport. The grant lives in the existing bot_event_access table (subject_kind='bot'), so no migration. Unit test: default-allow, channel deny, wildcard-deny + specific un-deny. 21 bot_event_policy tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(context): bot-subject workspace_read grant + resolver (P3-1)
feat(context): one shared per-target bundle finalizer (P2 of unified model)
feat(context): unified XML <context> prompt envelope (P1 of unified model)
fix(context): harden the bundle pipeline — no permission bypass
…ested feat(context): authz hardening + unified model P1/P2/P3-1 → develop
Make a bot's remote-workspace file resolvable by ANOTHER bot as a
consumer-governed reference (unified context model, Phase 3). A reader bot
pulls `workspace.read` and the gateway brokers a LIVE read of the owner's
file under the reader's OWN permission — superseding the inline snapshot.
- api/workspace.rs: `authorize_bot_workspace_read` (DB-only, testable) —
both bots must share the channel + owner must grant `workspace_read`
(member-allow default, deny-override); `read_workspace_file_as_bot`
wraps it and reuses the identity-free `workspace_call` transport.
- agent_bridge.rs: intercept `resource == "workspace.read"` at the
resource_req WS boundary (needs AppState for the workspace RPC; can't go
through the db-only resource::dispatch) → broker → resource_res frame.
- cheers-mcp-server: `read_workspace` tool → resource `workspace.read`
with {channel_id, bot_id, path, session_id?}.
- flows.rs: #[sqlx::test] for the auth gate (member-allow default,
non-member denied, deny-rule override). Live file fetch needs a
connector (kind check).
Offline owner → 400 (accepted tradeoff: a live read, not a stale snapshot).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… snapshot
Complete the unified context model: a remote-workspace pick becomes a pure
`workspace.read` REFERENCE the receiving bot resolves live under its own read
permission (via P3-2's broker), instead of an inline content snapshot.
Frontend:
- workspaceContextItem: drop the `content`/snapshot capture; emit
`verb: "workspace.read"` with {bot_id, path, session_id?} only.
- RemoteWorkspaceDialog "Attach": reference-only (no `edit` content passed);
tooltip/comment updated. ContextItem/ContextBundle lose the `preview` field
(nothing consumes it — MessageContextChips render label/kind).
Gateway (context_bundle.rs):
- sanitize_human_bundle: admit `workspace.read` (drop the deprecated
`workspace.file`); stop keeping any inline `preview` — bundles carry
references only. strip_previews stays defensive (old rows). Drop the now
unused MAX_PREVIEW_CHARS. finalize_bundle_for_target already authorizes
`workspace.read` by its params.channel_id — no change.
Connector (prompt.rs):
- Render `workspace.read` as a reference with a note pointing the agent at the
`read_workspace` tool (live pull under its own permission; post_message
fallback if the owner is offline). Legacy `workspace.file` snapshot renderer
retained for reading back old persisted messages.
Accepted tradeoff (user chose): a reference 400s when the owner bot is offline
(a live read), vs the always-available-but-stale snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(context): Phase 3 — workspace files as consumer-governed references
The design doc still described the deprecated inline-snapshot / `workspace.file` model as current. Update it to match shipped P3: - Remote-workspace pick is a consumer-governed `workspace.read` reference resolved live via the `read_workspace` MCP tool; no content captured. - New §4 documents the pull path (agent_bridge broker), the three pull-time authorization layers (channel membership + `workspace_read` grant + connector allowed_roots), and the offline-owner 400 fallback. - Record the permission decision (option B — "membership gate"): `workspace_read` defaults to member-allow with a deny hook; the owner-facing management path is deferred (built alongside dispatch's, both rejected by /event-access today). - Note P3 removed inline previews entirely — bundles carry references only; the `preview` field + connector snapshot renderer remain solely to read old rows. Docs-only; no code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…space_read) Bot-subject grants (which bot may command this one / read its workspace) were member-allow by default with deny only authorable by hand-editing the DB — the human role/user/group event-access endpoint rejects bot subjects, and the "managed separately" dispatch path was never built. This adds that dedicated management path so an owner can actually author the deny/allow override the resolvers already read (docs/design/RESOURCE_CONTEXT.md §4, BOT_DISPATCH.md D2). Backend (`api/bot_permission.rs` + `router.rs`): - GET/PUT/DELETE `/bots/:bot_id/bot-grants`. The endpoint speaks a stable grant kind and maps to the distinct rule keys: `dispatch`→(prompt, Dispatch), `workspace_read`→(workspace_read, Initiate). Reuses upsert_rule / delete_rule / list_rules_json; owner/admin gated like the rest of the module. - `list_bot_grants` filters to bot-subject rules, tags each with its grant kind, and returns the manageable kinds (+ member-allow default) and a catalog of candidate subject bots (co-members of a shared channel). - Validates the subject is a bot id (uuid) or "*"; extracted the shared `parse_future_rfc3339` expiry helper (dedups the event-rule path). Frontend: - `BotToBotGrantsSection.tsx` — list current bot-subject rules + a "New rule" form (pick grant kind, subject bot or "∗ any bot", allow/deny, expiry). Bot-wide scope in v1; per-channel rules still render. Mounted under the Permissions tab. - `api/bots.ts` — getBotGrants / upsertBotGrant / deleteBotGrant + types. Tests: unit tests for the grant-kind mapping round-trip; a #[sqlx::test] for the management lifecycle (author deny → broker denies → delete → member-allow restored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(bots): owner-facing bot-to-bot grant management (dispatch + workspace_read)
docs(context): sync RESOURCE_CONTEXT.md with the P3 reference model
… hover
Every authorization surface now shows a user-friendly name by default and
surfaces the raw technical name only in a hover tooltip:
- grantLabels.ts: add the missing `session_set_primary` label ("Set primary
session") — it was the one grant that leaked its raw class name into the
human matrix. Every gated event class now has a friendly label; the raw
`capability · event_class` already rides in the existing row/matrix tooltips.
- bot-grants (bot-to-bot): backend `list_bot_grants` now returns a clean
`label` ("Command this bot", "Read this bot's workspace") plus a `tech`
field ("prompt · dispatch (bot subject)", …). BotToBotGrantsSection renders
the friendly label and shows the raw grant-kind + `tech` key only in the
list-row badge and the form-option tooltips. Subject bots already showed
their raw id on hover.
No behavior change — labels/tooltips only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(bots): friendly permission labels by default, technical names on hover
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f79920e8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| path: file.path, | ||
| sessionId: effectiveSessionId || undefined, | ||
| }) |
There was a problem hiding this comment.
Preserve the selected workspace root in the context reference
When a user attaches a file while browsing an explicitly selected allowed root or additional session directory, file.path is relative to that root, but the reference records only the path and optional session. The receiving read_workspace path ultimately calls workspace_call with root: None, so it resolves the path against the default/first root and may read a different same-named file or return not found. Include the selected canonical root throughout the context item, MCP tool, and broker request.
Useful? React with 👍 / 👎.
|
|
||
| /** Map a selected substring to its 1-indexed inclusive line range within | ||
| * `content`. Returns `null` when the selection is empty or not found (e.g. a | ||
| * selection spanning virtualized/off-screen lines the DOM didn't hand back). */ |
There was a problem hiding this comment.
Derive the range from the actual selection offset
When the selected text occurs more than once in a file, content.indexOf(text) always identifies the first occurrence rather than the occurrence the user selected, so the generated fs.read reference can attach the wrong lines. Multi-line selections in CRLF content can also fail because only the selected string is normalized. Use the DOM selection/editor offsets, or otherwise disambiguate and normalize both strings, before computing the line range.
Useful? React with 👍 / 👎.
| const { content: body, ...opts } = sendParams; | ||
| await sendMessage(channel.channel_id, body, opts); | ||
| useContextPickStore.getState().clear(channel.channel_id); |
There was a problem hiding this comment.
Clear context after a successful retry
The new pending-context state is cleared only after the initial send succeeds. If that send fails and the failed bubble is later retried successfully, retryMessage replays the captured bundle but leaves the same context chips pending, causing an unrelated next message in the channel to resend the stale bundle unless the user manually removes it. Clear the channel's pending context after a successful retry as well.
Useful? React with 👍 / 👎.
Promotes 52 commits from
developtomain— the full body of the context-bundle / dispatch / Fleet / governance work. This is a major release, not a patch.What ships (by theme)
Dispatchcapability,subject_kind='bot', fail-closed gate + audit (feat: bot-to-bot dispatch under the grant matrix (D1, gateway) #197, D1).sanitize_human_bundle, per-targetfinalize_bundle_for_target, injection hygiene (fix(context): harden the bundle pipeline — no permission bypass #203)<context>prompt envelope (feat(context): unified XML <context> prompt envelope (P1 of unified model) #204)workspace.readreferences (no snapshot): resolver (feat(context): bot-subject workspace_read grant + resolver (P3-1) #206), broker + MCPread_workspacetool + frontend ref-only pick (feat(context): Phase 3 — workspace files as consumer-governed references #208)/bots/:id/bot-grantsfordispatch+workspace_read, with UI (feat(bots): owner-facing bot-to-bot grant management (dispatch + workspace_read) #210).Migrations (auto-run on gateway startup) — additive & safe
0046_dispatch_capability.sql— widenbot_event_accessCHECK constraints (adddispatchcapability +botsubject_kind). No narrowing.0047_message_context_bundle.sql—ADD COLUMN IF NOT EXISTS context_bundle JSONB(nullable).Deploy sequencing
workspace.read/context_bundlecontract — the new gateway drops the deprecatedworkspace.file, so a stale frontend would make remote-workspace picks vanish. Migrations run on gateway start.connector-v0.1.28(separate, signed — maintainer): bumppackages/cheers-acp-connector-rs0.1.27→0.1.28 (+ MCP), push theconnector-v0.1.28tag →release-connector.ymlbuilds + signs the binaries. This is what delivers the XML<context>envelope (P1) andworkspace.readrendering +read_workspacetool (P3) to prod agents. Self-update is opt-in; note "[update] breaks <0.1.27 configs".Pre-flight checklist
connector-v0.1.28; verify a bot self-updates#[sqlx::test]+ connector unit tests) but the final agent→read_workspacehop isn't live-verified🤖 Generated with Claude Code