Skip to content

Release: unified context model + bot-to-bot dispatch & governance + Fleet (develop → main)#212

Merged
haowei2000 merged 52 commits into
mainfrom
develop
Jul 16, 2026
Merged

Release: unified context model + bot-to-bot dispatch & governance + Fleet (develop → main)#212
haowei2000 merged 52 commits into
mainfrom
develop

Conversation

@haowei2000

Copy link
Copy Markdown
Collaborator

Promotes 52 commits from develop to main — the full body of the context-bundle / dispatch / Fleet / governance work. This is a major release, not a patch.

What ships (by theme)

Migrations (auto-run on gateway startup) — additive & safe

  • 0046_dispatch_capability.sql — widen bot_event_access CHECK constraints (add dispatch capability + bot subject_kind). No narrowing.
  • 0047_message_context_bundle.sqlADD COLUMN IF NOT EXISTS context_bundle JSONB (nullable).
  • No drops/deletes. Take a prod DB backup first (prudent).

Deploy sequencing

  1. Merge this PR (branch protection → done by a maintainer).
  2. Gateway + Frontend deploy TOGETHER to Hetzner (compose rebuild + up). They share the workspace.read / context_bundle contract — the new gateway drops the deprecated workspace.file, so a stale frontend would make remote-workspace picks vanish. Migrations run on gateway start.
    • Ships immediately: Fleet, dispatch governance, bot-grants UI, friendly labels, human permission matrix, pickup/handoff. Existing prod connectors keep rendering context bundles (old prompt format) — no break.
  3. Connector release connector-v0.1.28 (separate, signed — maintainer): bump packages/cheers-acp-connector-rs 0.1.27→0.1.28 (+ MCP), push the connector-v0.1.28 tag → release-connector.yml builds + signs the binaries. This is what delivers the XML <context> envelope (P1) and workspace.read rendering + read_workspace tool (P3) to prod agents. Self-update is opt-in; note "[update] breaks <0.1.27 configs".
    • Until agents update, a picked remote-workspace file is delivered as an inert reference (graceful — nothing breaks, feature just incomplete).

Pre-flight checklist

  • Prod DB backup
  • Reconcile prior prod drift (the "ops(tls): auto-ACME + dual-domain Caddy edge for domain migration #183 mismatches prod" note)
  • Deploy gateway + frontend together, confirm health + migrations applied
  • Cut & sign connector-v0.1.28; verify a bot self-updates
  • (optional) Close the P3 read round-trip end-to-end — currently CI-verified (auth #[sqlx::test] + connector unit tests) but the final agent→read_workspace hop isn't live-verified

🤖 Generated with Claude Code

haowei2000 and others added 30 commits July 14, 2026 17:37
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)
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>
haowei2000 and others added 22 commits July 15, 2026 16:31
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>
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
@haowei2000
haowei2000 merged commit 0c35264 into main Jul 16, 2026
11 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1783 to +1785
path: file.path,
sessionId: effectiveSessionId || undefined,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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). */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines 803 to +805
const { content: body, ...opts } = sendParams;
await sendMessage(channel.channel_id, body, opts);
useContextPickStore.getState().clear(channel.channel_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant