Skip to content

fix(workspace): scope a client's agent page to their own runs (#3139) - #3155

Merged
vybe merged 1 commit into
devfrom
fix/3139-client-recent-work-scope
Oct 1, 2026
Merged

vybe merged 1 commit into
devfrom
fix/3139-client-recent-work-scope

Conversation

@dolho

@dolho dolho commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • An external Workspace client opening an agent's page could see other people's runs of that agent: their run ids, triggers, start and end times, and durations. Message, cost and model were already hidden, but the rows themselves weren't limited to the viewer.
  • For a client, the page now shows only work they can account for:
    • their own turns;
    • the runs those turns spawned;
    • the agent's shared scheduled runs.
  • Scheduled briefs delivered to one other person are excluded: a seat's brief (bug: synchronous parallel chat_with_agent bypasses backlog (BACKLOG-001) — fan-out callers see terminal capacity rejections instead of queueing #498) is that person's. This came from the /cso --diff pass.
  • The stats band, first-try rate and "last active" use the same scope, so a client's numbers describe exactly the rows they can see.
  • The platform view is unchanged.

How

  • One filter: db/query_helpers.viewer_scope(table, viewer_email).

    • Email matching ignores case.
    • A missing viewer admits only shared scheduled runs, so it fails closed.
  • Applied in SQL before the LIMIT, as the Workspace shows a client 12 loop runs it cannot open, control, or explain #2423 loop exclusion is, so other people's activity can't push the viewer's own turns off the list. It also applies inside every analytics aggregate.

  • New keyword arguments, off by default: scope_to_viewer / viewer_email on:

    • get_agent_executions_summary;
    • get_agent_analytics;
    • first_try_stats, which moves from raw SQL text to the SQL builder to share the filter.

    Every other caller, including the operator analytics and Tasks routes, is unchanged.

  • Wiring: client_portal/agent_page.py passes the viewer's email from the verified portal session token into the list, stats and last-active reads, via _client_scope().

Changes

  • src/backend/db/query_helpers.py: viewer_scope()
  • src/backend/db/schedules/executions.py, db/schedules/analytics.py, database.py, client_portal/db.py: scoped accessors
  • src/backend/client_portal/agent_page.py: the viewer threaded through _recent_work, _stats and _last_active
  • tests/unit/test_3139_client_recent_work_scope.py (new)
  • tests/unit/test_2423_client_loop_visibility.py: the stubs accept the new keyword arguments
  • docs/memory/feature-flows/workspace-agent-page.md, docs/memory/requirements/core-agent.md

Test Plan

  • cd tests && pytest unit/test_3139_client_recent_work_scope.py -v: 12 passed, on real SQLite through the real accessors and the page module. Covered:
    • two clients on one agent each see only their own turns plus shared scheduled runs;
    • a spawned run counts as the viewer's;
    • email matching ignores case;
    • the scope runs before the LIMIT;
    • "last active" never reveals another person's newer run;
    • the stats total and per-day totals equal the visible rows;
    • success and first-try rates are over the client's rows;
    • a seat-delivered brief shows only to its person;
    • a missing viewer is shut out of personal briefs;
    • the platform view and the unscoped accessor are unchanged.
  • Mutation, each from a scratch copy:
    • fix reverted: 9 of 12 fail;
    • a scope that admits every row: 7 fail;
    • seat-brief exclusion removed: 3 fail.
  • Agent-page and Workspace shows a client 12 loop runs it cannot open, control, or explain #2423 suites: 64 passed.
  • Full unit suite locally: 10,816 passed. 1 failure, test_736_a2a_outbound_edges (IPv6-mapped address classification), is unrelated: its result depends on the local Python 3.12 runtime, and CI runs 3.13.
  • The viewer_scope SQL compiles for PostgreSQL.
  • /cso --diff:
    • no secrets, dependency, CI or Docker changes;
    • the viewer email comes from the verified portal session token, not the request;
    • one LOW finding (seat-delivered briefs visible to other clients), fixed here.

Fixes #3139

🤖 Generated with Claude Code

An external Workspace client opening an agent's page received other people's
runs of that agent: their run ids, triggers, start and end times, and
durations. The rows were never scoped to the viewer. Message, cost and model
were already projected away, but the timing still leaked (10 of 12 rows in the
ent#610 review walk).

For a non-platform viewer, every executions read on the page is now scoped in
SQL by one predicate, db/query_helpers.viewer_scope(). The viewer sees their
own turns (source_user_email), the runs those turns spawned (the inherited
source_channel_client), and the agent's scheduled runs, except a run of a
schedule that delivers to one other person (deliver_to_workspace_email, #498,
a seat's brief). Emails compare lower-cased, and a missing viewer admits shared
schedule runs only, so the scope fails closed.

The scope applies before the LIMIT, as the #2423 loop exclusion does, so
another person's busy day cannot push the viewer's own turns off the list. It
also covers every analytics aggregate, first_try_stats and "last active", so a
client's counts and rates are over exactly the rows they can see. first_try_stats
moves from text() to Core to share the predicate. The platform view and every
other caller of these accessors are unchanged (new keywords, defaults off).

Tests: test_3139_client_recent_work_scope.py, 12 tests on real SQLite through
the real accessors and page module. They cover two clients on one agent, a
spawned run, case-insensitive email matching, scope before the LIMIT, last
active, stats agreeing with the rows, rates, seat-delivered briefs, a missing
viewer, and an unchanged platform view. Verified by mutation: with the fix
reverted 9 of 12 fail; with a scope that admits every row, 7 fail; without the
seat-brief exclusion, 3 fail. The #2423 stubs take the new keywords. The
seat-brief exclusion came from the /cso --diff pass (one LOW finding).

Fixes #3139

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

/review report: fix/3139-client-recent-work-scope → dev

Files changed: 10 (+412/-58). Scope: CLEAN. Plan completion: 3/3 acceptance criteria done:

  • a client sees only their own turns and scheduled runs;
  • the stats match the rows;
  • two-client and platform tests exist.

Execution coverage

changed symbol executed by live consumer verdict
db/query_helpers.viewer_scope test_3139_client_recent_work_scope.py (real SQLite) the three accessors below ✅
get_agent_executions_summary(scope_to_viewer=) same agent_page._recent_work, _last_active ✅
get_agent_analytics(scope_to_viewer=) same agent_page._stats ✅
first_try_stats(scope_to_viewer=) (now Core) same agent_page._stats ✅
agent_page._client_scope / viewer_email threading same, via build_page client_portal/router.py portal_agent_page ✅

Fix mutations (each from a scratch copy):

  • agent_page.py reverted: 9 of 12 red;
  • a scope admitting every row: 7 red;
  • the seat-brief exclusion removed: 3 red.

Live check (local dev stack, 2026-10-01)

Two test clients were shared on proj-alpha and signed in through the real portal code flow. Each got one marked turn, and a seat brief was delivered to Bob. Reading GET /api/enterprise/client-portal/agents/proj-alpha/page:

viewer rows runs from this test total_executions
alice 1 alice only 1
bob 2 bob, bob's seat brief 2
admin (platform) 18 all 3 + 15 public-link runs by others 18

Before this PR, a client was served all 18, including other people's run ids and timings. The test data was removed afterwards (demo DB reset).

Critical findings

None. /cso --diff found one LOW (seat-delivered briefs visible to other clients), and it is fixed in this PR.

Informational

  • [I1] Performance: lower() on the email columns can't use an index (confidence 6/10). db/query_helpers.py: func.lower(table.c.source_user_email) == v. The agent_name predicate narrows first, so per-agent row counts keep this cheap. Revisit only if a single agent's executions reach the millions; a lower-cased stored column would be the fix.
  • [I2] Scope: the ratings tally stays agent-wide for clients (_rating_tally). It shows counts only, never run ids or timings, so it's outside this issue. Noted so it's a decision, not an oversight.

Clean categories

  • SQL: bound parameters only; the NOT IN subquery compiles for PostgreSQL.
  • Auth: the viewer email comes from the verified portal session token.
  • Callers: every other caller of the three accessors passes no scope, so its behaviour is unchanged (grepped).
  • Docs: feature flow and requirement updated; guard: 0 hits.

Summary: Critical 0 · Informational 2 · Scope clean.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train #3172

@vybe
vybe merged commit 06377ce into dev Oct 1, 2026
26 checks passed
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