Skip to content

[codex] Add scoped observer tokens#215

Merged
willwashburn merged 15 commits into
codex/node-kind-role-adapterfrom
codex/scoped-observer-tokens
Jun 25, 2026
Merged

[codex] Add scoped observer tokens#215
willwashburn merged 15 commits into
codex/node-kind-role-adapterfrom
codex/scoped-observer-tokens

Conversation

@willwashburn

@willwashburn willwashburn commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

  • add scoped ot_live_* observer tokens with storage, lifecycle routes, scope checks, and REST/stream filtering
  • require stream:read observer tokens for /v1/ws while keeping agents on node delivery and nodes on /v1/node/ws
  • expose observer-token helpers in TypeScript, Python, Rust, and Swift SDKs and document the four-token contract in README/OpenAPI
  • address review feedback: thread-reply @mentions bypass channel mute delivery suppression, and response-mode HTTP push deliveries stay queued/retryable until an explicit ack signal

Validation

  • npm --workspace @relaycast/engine test -- nodeUpgradeAuth.test.ts conformance/node.test.ts conformance/nodeDeliveryContracts.test.ts
  • npm --workspace @relaycast/types test -- sdk-openapi-sync.test.ts
  • npx turbo build && npx turbo test && npx turbo lint
  • npx turbo lint
  • .venv/bin/python -m pytest tests in packages/sdk-python
  • cargo test --manifest-path packages/sdk-rust/Cargo.toml
  • swift test --package-path packages/sdk-swift

Stacked on #214. Refs #213.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 86a5e823-fdb7-4155-bca5-1a5318009943

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/scoped-observer-tokens

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces scoped, read-only observer tokens (ot_live_*) to secure workspace realtime streams and read-only REST operations, updating the database schema, engine routes, documentation, and SDKs (TypeScript, Python, Rust, Swift) accordingly. It also fixes thread reply mention deliveries for muted members and ensures response-mode HTTP push deliveries remain queued when an ACK signal is missing. Feedback on the changes includes addressing a logical bug where DM events are incorrectly blocked by public channel filters, optimizing token authentication by performing lastUsedAt database updates asynchronously to avoid lock contention, refining the mention-parsing regex to prevent matching email addresses, and utilizing Zod's built-in .datetime() validator for stricter ISO-8601 timestamp validation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +319 to +331
if ((type === 'dm.received' || type === 'group_dm.received') && !observerAllowsConversation(
observer,
typeof event.conversation_id === 'string' ? event.conversation_id : null,
)) {
return false;
}

if (!observerAllowsChannel(observer, {
id: typeof event.channel_id === 'string' ? event.channel_id : null,
name: eventChannelName(event),
})) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When an observer token has channel filters (e.g., channel_names) and include_dms is enabled, DM events will be incorrectly blocked because they are subjected to the observerAllowsChannel check, which will return false since DM conversations do not match public channel filters. We should bypass the channel filter check for DM events.

  const conversationId = typeof event.conversation_id === 'string' ? event.conversation_id : null;
  const isDm = conversationId !== null || type === 'dm.received' || type === 'group_dm.received';

  if (isDm) {
    if (!observerAllowsConversation(observer, conversationId)) {
      return false;
    }
  } else {
    if (!observerAllowsChannel(observer, {
      id: typeof event.channel_id === 'string' ? event.channel_id : null,
      name: eventChannelName(event),
    })) {
      return false;
    }
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 3c115b0: observerAllowsEvent now treats events with conversation_id or DM event types as DM resources, so channel filters are bypassed while include_dms / DM id filters still apply. Added conformance coverage for a DM stream observer with channel filters plus include_dms.

Comment on lines +229 to +237
try {
await db
.update(observerTokens)
.set({ lastUsedAt: now })
.where(eq(observerTokens.id, row.id));
} catch {
// last_used_at is best-effort audit metadata; authentication should not fail
// solely because a read replica or adapter rejects this update.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Updating lastUsedAt synchronously on every token authentication request can introduce significant database write load and lock contention, especially in SQLite where concurrent writes are serialized. Since lastUsedAt is best-effort audit metadata, we should perform this update asynchronously in the background without blocking the authentication path.

  // last_used_at is best-effort audit metadata; do not await to avoid blocking the auth path
  void db
    .update(observerTokens)
    .set({ lastUsedAt: now })
    .where(eq(observerTokens.id, row.id))
    .catch(() => {});

Comment thread packages/engine/src/engine/thread.ts Outdated
Comment on lines +48 to +50
const mentionPattern = /@(\w+)/g;
const mentionMatches = data.text.match(mentionPattern) || [];
const mentionedHandles = new Set(mentionMatches.map((m: string) => m.slice(1)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The regex /@(\w+)/g matches any @ followed by word characters, which will incorrectly match email addresses (e.g., user@example.com will match @example as a mention). We should use a more robust pattern that requires a word boundary or space before the @ symbol.

  const mentionPattern = /(?:^|\s)@(\w+)/g;
  const mentionedHandles = new Set<string>();
  let match;
  while ((match = mentionPattern.exec(data.text)) !== null) {
    mentionedHandles.add(match[1]);
  }

Comment on lines +26 to +28
const isoTimestamp = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'expires_at must be an ISO-8601 timestamp',
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using Date.parse inside a custom Zod .refine can be permissive and parse non-standard date formats. Since Zod has a built-in .datetime() validator that strictly enforces ISO-8601 UTC format, we should use it instead for better validation and cleaner code.

const isoTimestamp = z.string().datetime({ message: 'expires_at must be an ISO-8601 timestamp' });

@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: b32d86adc2

ℹ️ 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 thread packages/engine/src/routes/message.ts Outdated
Comment on lines +206 to +207
if (!observerAllowsChannel(getObserverTokenFromContext(c), channel)) {
return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce agent filters on message reads

When an observer token is created with filters.agent_ids, this route only verifies the channel and then returns the full messageEngine.getMessages result, so a token scoped to one sender can read messages from every agent in an allowed channel via REST. The workspace stream already applies observerAllowsAgent, so channel message reads need the same per-message agent_id filtering before responding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 3c115b0: channel message reads now filter each returned message through observerAllowsMessage, which enforces agent_ids and created_after. Search/console/activity filtering also uses the same helper. Added conformance coverage for agent_ids on channel message reads and search.

Comment thread packages/engine/src/routes/reaction.ts Outdated
reactionRoutes.get(
'/messages/:id/reactions',
requireAuth,
requireWorkspaceRead(['messages:read', 'reactions:read']),

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 Require reactions scope for reaction reads

requireWorkspaceRead grants access when an observer has any listed scope, so this allows a token with only messages:read to call /v1/messages/:id/reactions and receive reaction metadata; the later resource check only applies channel/DM filters. Since reactions:read is a distinct observer scope, this endpoint should require that scope rather than treating messages:read as sufficient.

Useful? React with 👍 / 👎.

...(filters?.dm_conversation_ids?.length ? { dm_conversation_ids: [...new Set(filters.dm_conversation_ids)] } : {}),
...(filters?.agent_ids?.length ? { agent_ids: [...new Set(filters.agent_ids)] } : {}),
...(filters?.event_types?.length ? { event_types: [...new Set(filters.event_types)] } : {}),
...(filters?.created_after ? { created_after: filters.created_after } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce created_after observer filters

filters.created_after is accepted and persisted, but no observer filter path compares it against message or event timestamps, so a token minted with a cutoff can still read older REST/search results and receive events before that cutoff. Either reject this filter until it is implemented or apply it wherever observer-visible data is returned.

Useful? React with 👍 / 👎.

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed the review in d33cff1.

  • Centralized the DM gate so DM resources require both dms:read and filters.include_dms: true; this now covers search/activity/console logs/thread replies/reactions/stream filtering, not just /dm/* and single-message reads.
  • Added conversation_id to DM reaction/read stream payloads so they go through the same DM gate instead of looking like generic channel events.
  • Applied observer filters to /console/stats, /console/agents, and /console/costs by aggregating the same visible message-log rows used by /console/messages.
  • Preserved created_at on client stream events for created_after, and carried agent/channel fields through reaction/member/status transforms where available.
  • Converted duplicate observer token names to 409 observer_token_name_conflict and documented the scope/filter model plus header-preferred auth.

Verification:

  • npm run typecheck --workspace @relaycast/engine
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run test --workspace @relaycast/engine

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed the DM-channel bypass in 284b6c8.

  • Observer channel checks now reject channel_type !== 0 when a channel resource is resolved by name.
  • GET /v1/channels/:name/messages now validates the observer-visible channel resource before reading messages, so deterministic dm-* channel names return 404 for observer tokens instead of exposing transcripts under messages:read.
  • GET /v1/channels/:name and /members use the same observer channel resource path, so DM existence/member lists are also hidden from channels:read.
  • Added a regression test that computes the real dm-${sha256(workspaceId:agentA:agentB).slice(0,24)} name and asserts messages, details, and members all 404 for an observer with messages:read + channels:read.

Verification:

  • npm run typecheck --workspace @relaycast/engine
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run test --workspace @relaycast/engine

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed this review batch in c0cd16c.

  • File metadata routes now honor observer filters. /files/:id and /files check uploader agent_ids, created_after, and attached message channel/DM context before returning metadata; denied single-file reads return 404 before creating a signed download URL.
  • Observer last_used_at writes are debounced to 30s, mirroring the agent lastSeen pattern.
  • Workspace stream filtering now enforces granular event scopes: stream:read opens the socket, while event families require their matching read scope (messages:read, threads:read, reactions:read, files:read, agents:read, etc.).
  • message.read, file.uploaded, and webhook.received stream transforms now carry enough id metadata for agent/channel filters to work.
  • /agents/:name/events now applies created_after.
  • Thread observer responses no longer overwrite parent.reply_count with the filtered page length.
  • Observer token serialization is lenient for retired/unknown scopes, and new observer tokens now populate created_by with the workspace id.

Verification:

  • npm run typecheck --workspace @relaycast/engine
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run test --workspace @relaycast/engine

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed the latest observer review in 38e9455.

  • DM thread replies on /v1/ws now carry conversation_id from postReply through wsTransform, so observerAllowsEvent routes them through dms:read + include_dms instead of channel filtering. Added regression coverage for stream:read + threads:read without dms:read receiving zero DM thread replies, and a positive dms:read case.
  • Documented file.uploaded stream scoping as upload-time only: it is filtered by files:read/agent/event/time, while channel and DM visibility are enforced on REST file reads and message attachments.
  • Batched observer file-resource lookups for /files filtering to remove the N+1 query pattern.
  • update/rotate now only affect active observer tokens; revoked tokens return 404.
  • hasObserverScope now enforces against the same known-scope set exposed by public serialization, so retired scopes are hidden and unenforced consistently.

Verification:

  • npm run typecheck --workspace @relaycast/engine
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run test --workspace @relaycast/engine

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed this review batch in 3eb1bcf.

  • /nodes, /nodes/:name, and /nodes/:name/agents now honor observer agent_ids filters. With no agent filter, nodes:read still exposes the fleet roster; with agent_ids present, node rows are limited to nodes hosting visible agents, /nodes/:name 404s when no visible binding exists, /nodes/:name/agents returns only visible bindings, and active_agents is adjusted to the visible count.
  • search/activity/console message filtering now carries channel_type through the shared observer filter so observerAllowsChannel can reject non-normal channels directly instead of relying only on conversation_id being populated.
  • /activity now includes channel_id for channel-message items, so channel_ids-only observers get the expected activity rows. README, OpenAPI, and @relaycast/types were updated for that field.
  • Added observer regressions for /nodes* paths, activity channel_id filtering, DM message.read stream gating, reaction stream conversation gating with reactions:read present, and repeated revoke returning 404.
  • Left file.uploaded stream behavior as the documented upload-time metadata design: it still has no channel/DM attachment context at upload completion, while REST file reads and message attachments enforce channel/DM visibility.

Verification:

  • npm run typecheck --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run build --workspace @relaycast/types
  • npm run test --workspace @relaycast/engine

@willwashburn

Copy link
Copy Markdown
Member Author

Addressed this batch in efa6971.

What changed:

  • Made channel_type authoritative for single-message observer resources. getMessageObserverResource now includes it, and message/reaction/thread single-resource routes use one helper that treats channel_type != 0 as private and routes through the DM conversation gate instead of falling back to channel visibility.
  • Added regression coverage for an orphan private channel message with no conversation_id; messages:read, reactions:read, and threads:read observers now all get 404 without dms:read.
  • Tightened unattached file visibility: observers with channel or DM filters no longer see unattached files as a workspace-wide fallback.
  • Applied event_types filtering to /activity and console message aggregates.
  • Rejected already-expired expires_at values on observer token create/update.
  • Added typed observer scope enums in Rust and Swift, and made Swift ObserverToken.filters decode with a default when omitted.
  • Updated OpenAPI responses for duplicate-name 409s and rotate 404s.

On file.uploaded: I left the existing upload-time stream semantics as metadata-only and not channel scoped, because the upload event is emitted before any attachment/message context exists. The REST file filtering and later DM/message attachment paths are scoped; changing the stream would require re-emitting or delaying until attachment context exists.

Verification:

  • git diff --check
  • npm run typecheck --workspace @relaycast/engine
  • npm run lint --workspace @relaycast/engine
  • npm run build --workspace @relaycast/engine
  • npm run test --workspace @relaycast/engine -- observerToken.test.ts
  • npm run test --workspace @relaycast/engine
  • npm run build --workspace @relaycast/types
  • npm run build --workspace @relaycast/sdk
  • npm run test --workspace @relaycast/sdk
  • cargo test in packages/sdk-rust
  • swift test in packages/sdk-swift

Delete generated relay workspace metadata (memory/workspace/.relay/outbox/capabilities.json and memory/workspace/.relay/state.json) and remove the large SDK setup workflow script (workflows/sdk-setup-client-80-100.ts). These are cleanup deletions of workspace state/artifact files and an obsolete workflow; no functional source code changes were made.
@willwashburn willwashburn merged commit 2c9e545 into codex/node-kind-role-adapter Jun 25, 2026
3 checks passed
@willwashburn willwashburn deleted the codex/scoped-observer-tokens branch June 25, 2026 15:10
willwashburn added a commit that referenced this pull request Jun 25, 2026
* Split node kind and role for delivery adapters

* Address node delivery review feedback

* Make realtime delivery node-only

* [codex] Add scoped observer tokens (#215)

* Add scoped observer tokens

* Add observer token SDK parity coverage

* Address observer token review feedback

* Tighten observer token scope filtering

* Block observer access to DM channels

* Align observer file and stream filters

* Close observer stream DM thread gaps

* Filter observer node and channel results

* Make observer DM gating authoritative

* Remove relay workspace state and SDK workflow

Delete generated relay workspace metadata (memory/workspace/.relay/outbox/capabilities.json and memory/workspace/.relay/state.json) and remove the large SDK setup workflow script (workflows/sdk-setup-client-80-100.ts). These are cleanup deletions of workspace state/artifact files and an obsolete workflow; no functional source code changes were made.

* Align observer auth contracts

* Clean up migration leftovers and React races

* Remove dead dashboard auth and feed code

* Validate channel message attachments

* Wire API usage quotas

* Fix node delivery review blockers

* Add replay demo script and trajectory records

Introduce a replay utility and add multiple AgentWorkforce trajectory artifacts. Adds .agentworkforce/replay-demo.py to rebuild the Relay Rush demo workspace (teardown, re-register agents, replay messages/reactions, checkpoint state). Also adds numerous trajectory JSONs, traces and summary files under .agentworkforce/trajectories (active and completed for 2026-06) to record work history and traces. Minor .gitignore update included.

* Address PR #214 review feedback

Node delivery / capacity:
- node.ts: recompute delivery adapter + maxAgents when node shape changes;
  make post-node.register pending flush best-effort to avoid double error reply
- routes/node.ts: return node_not_found (not 200 []) for observer-hidden nodes
- realtime.ts: drop dead no-op agent-socket flush; revalidate observer tokens
  at publish time (5s TTL) so revoked/narrowed tokens stop receiving
- fanout.ts: add message.read and message.reacted to node-delivery skip-set to
  kill duplicate context frames

Release flow:
- action.ts: guard release double-decrement, clear agent node location on
  release, stop release retries from falling through to generic placement
- mcp test: fix release fixture input shape (name vs agent_name)

Auth / observer scoping:
- auth/index.ts: reject node tokens on user/workspace routes (node tokens
  authenticate only via node transport)
- search.ts: allowNode:false
- observerToken.ts: skip channel filter for workspace-wide events so agent_ids
  can match; still fail closed for channel-scoped events missing metadata
- channel.ts: filter member lists by observerAllowsAgent
- routes/console.ts: classify conversation logs via channel_type, not both DM types
- workspace.ts + dmAll.ts + types/workspace.ts: plumb agent_id into DM preview
  and filter previews via observerAllowsMessage

Other correctness:
- dm.ts: validate attachments before creating conversation rows
- ChatFeed.tsx: clear stale messages on conversation switch; fix stale pagination
- sdk-rust ws.rs: surface auto-ack send failures instead of dropping them
- sdk-typescript ws.ts: dedupe node deliver frames via seenEventIds
- sdk-python ws.py: stop _handle_node_frame swallowing reply/error/pong/resync_ack

Docs:
- openapi.yaml: /v1/ws is query-token only (no Authorization header)
- README.md: align /node/ws -> /v1/node/ws

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

* Centralize auth token kind validation

---------

Co-authored-by: Claude Opus 4.8 (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.

1 participant