Skip to content

feat(agent-collab): Phase 1 + early Phase 2 — agent-dm pod type, sharePod auth, codex preset - #275

Closed
samxu01 wants to merge 5 commits into
mainfrom
feat/agent-dm-and-codex
Closed

feat(agent-collab): Phase 1 + early Phase 2 — agent-dm pod type, sharePod auth, codex preset#275
samxu01 wants to merge 5 commits into
mainfrom
feat/agent-dm-and-codex

Conversation

@samxu01

@samxu01 samxu01 commented May 3, 2026

Copy link
Copy Markdown
Contributor

Implements Phase 1 + early Phase 2 of docs/plans/agent-collaboration-surfaces.md.

Summary

Phase 1 (the demo blocker)

  • agent-dm Pod.type — any 2-member DM (any combination of user/bot).
  • Allow-list patches in messageController.ts and agentMentionService.ts:enqueueDmEvent so the new pod type doesn't silent-drop. Bot senders are intentionally allowed in agent-dm (the whole point) but still blocked in legacy agent-admin.
  • dmService.sharePod(a, b) — single source of truth for the §3.7 co-pod-member rule.
  • dmService.getOrCreateAgentDmRoom(memberA, memberB) — idempotent on the unordered pair, AgentInstallation upserts for any bot members (heartbeat off).
  • AgentInstallation.upsert(...) — new static via findOneAndUpdate(upsert+setOnInsert). Re-fires + later admin installs converge on a single row instead of throwing or duplicating.
  • POST /api/agents/runtime/agent-dm — accepts target = {agentName,instanceId} | {userId} | {alias}; resolves alias against pod.contacts then caller's contacts; runs sharePod auth (bypassed only when resolution came from pod.contacts — admin-binding carve-out); drops "DM started" system event in originPodId.
  • Mention-driven autoJoin in agentMentionService behind ENABLE_MENTION_AUTOJOIN env flag (default off): unresolved aliases fall through to pod.contacts → sender.contacts; if resolved outside pod.members, runs sharePod (same admin-binding carve-out) + AgentInstallation.upsert + adds to pod.members + drops system event + fires chat.mention.
  • Schema additions, all with safe defaults (existing rows return empty, never throw on read):
    • User.contacts: [ContactEntry] for both human and bot users.
    • Pod.contacts: Map<alias, {agentName, instanceId}> for per-pod bindings.
    • AgentInstallation.config.role reuses existing free-form config map.
  • V2 sidebar treats agent-dm as Private; agent↔agent (both bot members) gets an "AA" icon to distinguish from human DMs.
  • getAllPods extends membership filter to agent-dm so personal pod types stay user-scoped.

Phase 2 substrate

  • codex agent preset on the openclaw clawdbot gateway — code-quality-focused, reactive (no heartbeat), routes through LiteLLM via the codex-auth-rotator already live. Replaces the operator-specific sam-local-codex stop-gap that broke when account-2/3 tokens were refreshed for the rotator.
  • PATCH /api/pods/:id/contacts — pod creator OR global admin only (intentionally tighter than member-only; see Self-review Add basic unit tests for backend functions #1 below). One endpoint for all aliases (codex/claude/reviewer/…).

Out of scope for this PR:

  • Manage-tab UI to expose the PATCH endpoint (frontend follow-up; the API is shipped).
  • Pixel/Theo/Ops heartbeat-template cutover (registry.ts) — depends on the codex agent being installed in the demo pods, which is a runtime-config step.

Self-review pass

Ran the code-reviewer agent on 2baa52d266. Three Important findings, all addressed in a186406855:

  1. PATCH /pods/:id/contacts was member-gated — would let any pod member pin an arbitrary agent and bypass sharePod. Tightened to creator OR global admin (db lookup; we don't trust JWT-only role claims).
  2. TOCTOU on the admin-binding carve-out — pod.contacts was fetched twice. Now fetched once and the source is propagated ('pod' | 'sender') through resolveContactAlias.
  3. Ghost-User materialization on target.agentName — typos would create permanent bot User rows. Probe via User.findOne first; 404 on miss.

Reviewer also flagged a Question about backfilling existing agent-room rows missing AgentInstallation — that's a runtime fix using the existing kubectl-exec script from e78b5df241, not blocked by this PR.

Test plan

  • 25 unit + service tests passing (dmService.agentDm.test.ts + extended agentMentionService.test.js).
  • tsc:check clean.
  • Browser-verify on dev once merged: open /v2/pods (no socket-auth flood), POST to /api/agents/runtime/agent-dm with valid + invalid agentName (404 on miss, no ghost row), confirm allow-list lets messages route to the agent in an agent-dm pod.
  • After merge, install codex preset in a demo pod and pin via PATCH /pods/:id/contacts to verify the resolution chain end-to-end.

🤖 Generated with Claude Code

samxu01 and others added 4 commits May 3, 2026 01:59
…gged autoJoin

Implements Phase 1 of docs/plans/agent-collaboration-surfaces.md:

- New `agent-dm` Pod.type for any 2-member DM (any combination of
  user / bot). Same-diff allow-list adds in messageController.ts and
  agentMentionService.ts so messages don't silent-drop on the new
  type. Bot senders are allowed in agent-dm (the whole point) but
  still blocked in legacy agent-admin/agent-room.
- `dmService.sharePod(a, b)`: single source of truth for the §3.7
  co-pod-member rule. Returns true iff a and b share at least one
  pod. Used by both `getOrCreateAgentDmRoom` and the autoJoin gate.
- `dmService.getOrCreateAgentDmRoom(memberA, memberB)`: creates an
  agent-dm pod with both members, idempotent on the unordered pair,
  AgentInstallation upserts for any bot members (heartbeat off).
- `AgentInstallation.upsert(...)`: idempotent variant of `install`
  via findOneAndUpdate(upsert+setOnInsert). Mention-driven autoJoin
  re-fires + later admin installs converge on a single row instead
  of throwing.
- `POST /api/agents/runtime/agent-dm`: agent-runtime endpoint for
  opening a 2-agent DM. Accepts target = {agentName,instanceId} |
  {userId} | {alias}; resolves alias against pod.contacts then
  caller's contacts; runs sharePod auth (bypassed when resolution
  came from pod.contacts — admin-binding carve-out); drops
  "DM started" system event in originPodId via commonly-bot.
- Mention-driven autoJoin in agentMentionService behind
  ENABLE_MENTION_AUTOJOIN env flag (default off): unresolved aliases
  fall through to pod.contacts → sender.contacts; if resolved
  outside pod.members, runs sharePod auth + AgentInstallation.upsert
  + adds to pod.members + drops system event + fires chat.mention.
- Schema additions, all with safe defaults so existing rows don't
  throw on read:
  - User.contacts: [ContactEntry] (alias, agentName/instanceId or
    targetUserId, role, source, pinned, addedAt) — applies to both
    human and bot users.
  - Pod.contacts: Map<alias, {agentName, instanceId}> for per-pod
    bindings.
  - AgentInstallation.config.role uses the existing free-form
    config map; the new `codex` role lookups in P2 read it via
    config.get('role').
- V2 sidebar treats `agent-dm` as Private; agent↔agent (both bot
  members) shows an "AA" icon to distinguish from human DMs.
- `getAllPods` extends membership-filter to agent-dm so personal
  pod types stay user-scoped (matches existing agent-room rule).

Tests:
- DMService.sharePod (true/false/bot-bot/bot-human/self).
- getOrCreateAgentDmRoom (create, idempotent on unordered pair,
  AgentInstallation rows for both bots, self-DM refused, naming).
- AgentInstallation.upsert idempotency + reactivation of
  uninstalled rows.
- enqueueDmEvent allow-list for agent-dm + bot-sender allowed in
  agent-dm but blocked in agent-admin.

All 25 unit/service tests pass. tsc:check clean.

Phase 2 (clawdbot codex preset + heartbeat cutover) lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of docs/plans/agent-collaboration-surfaces.md:

- New `codex` agent preset on the openclaw runtime — same shape as
  `engineering-copilot`, no heartbeat (reactive only), runs gpt-5.4-mini
  via the LiteLLM rotator. Persona is a code-quality-focused
  collaborator, not a CLI / not a runtime — it IS the agent. Replaces
  the operator-specific `sam-local-codex` stop-gap that broke when
  account-2/3 tokens were refreshed for the rotator.
- `PATCH /api/pods/:id/contacts` — pod-level alias bindings for the
  new pod.contacts Map. Body `{ contacts: { codex: { agentName,
  instanceId } | null } }`. Pod-member-only auth; setting an alias to
  null deletes it. Single endpoint for all aliases (codex, claude,
  reviewer, …) so we don't fragment the API.

Phase 2 still pending in this PR scope:
- Manage-tab UI to expose the PATCH endpoint (frontend follow-up).
- Heartbeat cutover for Pixel/Theo/Ops (registry.ts heartbeat
  templates) — depends on the codex agent being installed in the
  demo pods, which is a runtime-config step not a code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… users

Three Important findings from the self-review pass on 2baa52d:

1. PATCH /api/pods/:id/contacts was member-gated. The contacts map is
   the admin-binding carve-out that lets `@codex` resolve outside
   sharePod for mention-driven autoJoin (§3.4). Member-only writes
   would let any pod member pin an arbitrary agent and have the
   resolver bypass the co-pod-member rule. Now requires pod creator
   OR global admin (db lookup; we don't trust JWT-only role claims).

2. The mention-driven autoJoin path fetched pod.contacts twice — once
   to compute `fromPodBinding` and once inside resolveContactAlias.
   An admin removing a binding between the two reads would let the
   stale `fromPodBinding=true` skip sharePod auth even though the
   resolution then fell to sender.contacts. Refactored
   resolveContactAlias to accept the already-fetched pod.contacts
   and return the source ('pod' | 'sender'); the gate uses that
   single snapshot for both lookup and auth.

3. POST /api/agents/runtime/agent-dm with target={agentName} called
   getOrCreateAgentUser unconditionally — a typo would materialize a
   permanent ghost bot User row. The legacy /room endpoint had the
   same shape but we explicitly choose stricter behavior on the new
   endpoint: probe via User.findOne first; 404 on miss with the
   resolved name in the error body so callers can act on it.

Also tightened the AgentInstallation.upsert reactivation test to
verify identity fields (agentName, podId, instanceId) are unchanged
on the reactivation path — proves the unique-index filter matched
rather than a coincidental ObjectId collision.

All 25 unit + service tests still passing; tsc:check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… invariants

Updates docs/agents/AGENT_RUNTIME.md to reflect the Phase 1 changes:

- DM auto-route allow-list now lists all three pod types (agent-admin,
  agent-room, agent-dm) and explains the bot-sender carve-out for
  agent-dm.
- Adds the new agent-dm and mention-autoJoin paths to the
  membership-vs-install table.
- Calls out `AgentInstallation.install` vs `upsert` semantics so the
  next person picking either method knows the rule of thumb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread backend/routes/agentsRuntime.ts Fixed
Comment thread backend/routes/agentsRuntime.ts Fixed
Comment thread backend/routes/agentsRuntime.ts Fixed
Comment thread backend/routes/agentsRuntime.ts Fixed
Comment thread backend/routes/pods.ts Fixed
Comment thread backend/routes/pods.ts Fixed
Comment thread backend/services/dmService.ts Fixed
…ery coercion, format string

CodeQL flagged three real issues against the new endpoints + service code:

- POST /api/agents/runtime/agent-dm and PATCH /api/pods/:id/contacts
  had no rate limiter. Added `phase4RateLimit` (existing helper) on the
  agent-dm route and a fresh `podAdminRateLimit` (60/min/IP) on the
  contacts route — the contacts endpoint isn't volume-sensitive but
  shouldn't be unbounded either.

- `Pod.findById(originPodId)` and `Pod.findById(originPodId)` (twice in
  the agent-dm endpoint) read directly from req.body. Without
  coercion, a request body of `{ originPodId: { $gt: '' } }` slips
  through Mongoose's implicit object-as-query interpretation. Coerced
  to a string up front and use the coerced value at all three call
  sites (resolution, auth check, system-message post).

- `dmService.getOrCreateAgentDmRoom` interpolated `member.agentName`
  (request-derived, lowercased but still user-controlled) into a
  console.error format string. Restructured to pass values as
  separate console arguments so user input can't reach a format spec.

All 25 unit + service tests still pass; tsc:check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Request: { target: { agentName, instanceId? } | { userId } | { alias }, originPodId? }
* Response: { room, autoJoined: bool }
*/
router.post('/agent-dm', agentRuntimeAuth, phase4RateLimit, async (req: any, res: any) => {
@samxu01

samxu01 commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

Merged to main as d5b7198 via local squash (per project convention — see feedback-pr-merge-pattern memory).

@samxu01 samxu01 closed this in d5b7198 May 3, 2026
samxu01 added a commit that referenced this pull request May 3, 2026
Test & Coverage was red on every PR since #275 due to stale fixtures, not
stale code. Three test files updated:

1. podController.test.js — populate signatures now include `isBot`; add
   DM_POD_TYPES_GUARD to agentIdentityService jest mock so joinPod's
   runtime require resolves the ADR-001 §3.10 guard.

2. dmService.test.ts — agent-room labels now come from User.botMetadata
   .displayName (per f9ff990) instead of `<runtime> (<instance>)`.
   Update fixture and replace one stale test with two intent-aligned
   ones (displayName overrides args; instanceId is fallback).

3. registry.list-agents-config.test.js — add jest mocks for User,
   dmService, agentIdentityService so the 10s timeout (Mongoose
   User.find against unconnected DB) goes away.

Pure test repair, zero production changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
samxu01 added a commit that referenced this pull request May 4, 2026
Fills the human-facing UX for the agent-dm pod type shipped in PR #275.

Gap #1 — Read-only banner + composer disable
Humans with §3.7 read-access (sharing a pod with one of the bots) can VIEW
but not POST in agent-dm rooms. Without this, the composer accepts input
and silently 401s on send. Now: composer is replaced with a soft-toned
banner ("Read-only — you're observing this conversation"). Bot-bot variant
phrases it as "X and Y are talking directly. To engage them, @-mention
either in a shared team pod."

Gap #2 — System event card for "🤝 X and Y started a DM"
The §3.8 announcement post commonly-bot drops in a team pod when two
agents start a DM rendered as raw markdown — including a `[view](/v2/pods
/<id>)` link that opened in a new tab (markdown anchors default to
`target="_blank"`, which is wrong for in-app navigation). Detect by
content shape (no metadata column on PG messages yet), render as a
chrome-light card with router-aware `navigate(...)` button.

Gap #3 — "Between agents · N" sub-section in Private filter
Bot↔bot rooms (where the human is read-only) were mixed in with regular
DMs in the Private filter, hiding the relationship. Override `groupPods`
when filter==='private': split into Pinned, Direct messages, and Between
agents · N — the count doubles as a glanceable "how much agent-to-agent
activity is happening" indicator.

Gap #4 — Empty state copy for fresh agent-dm rooms
Generic "Talk to your team" copy isn't helpful in a 1:1 DM context.
Branch on bot-bot vs human-bot to render appropriate empty-state text
("X and Y haven't talked yet — they'll DM each other when one of them
needs the other's help" vs "No messages yet — this is a private 1:1
conversation").

Design review per CLAUDE.md: no gradients, borders not shadows, one
accent. Token discipline (use --v2-bg-subtle not undefined --v2-surface
-soft). v2-root button.X specificity prefix on the syscard CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants