Skip to content

feat(connectors): add an optional team access hook to the connector seam - #1912

Merged
AlexLiu190625 merged 10 commits into
xorbitsai:mainfrom
AlexLiu190625:feat/connector-access-seam
Sep 2, 2026
Merged

feat(connectors): add an optional team access hook to the connector seam#1912
AlexLiu190625 merged 10 commits into
xorbitsai:mainfrom
AlexLiu190625:feat/connector-access-seam

Conversation

@AlexLiu190625

Copy link
Copy Markdown
Collaborator

Split out of #1661, which is being broken into pieces that can each be reviewed on
their own. This one carries the seam and nothing else; the routing changes that
consume it stay on that branch.

What this changes

Standalone xagent keeps MCP servers and custom APIs user-owned: whoever created a
connector is the only one who can edit it. connector_team_scope already exists as the
place where a downstream application that installs the hooks can overlay team ownership
on top of that, and it already carries slots for delete, rename, and two flavours of
visibility.

This adds the one slot that was missing: an access hook, which answers whether the
caller's team links a given connector, and whether the caller may edit it.

Nothing is wired to a route in this PR. This is the type, the validation, the failure
handling, and the tests for that hook — and only those.

The hook contract

resolve_connector_access(db, user_id, refs) takes a collection of
(connector_type, connector_id) refs and asks the installed hook once, no matter how
many refs are passed. Batching is the point of the signature: the question a caller
actually has is "what is this caller's team's relationship to these connectors", and
answering it should not cost one hook call per connector.

Two rules make the answer unambiguous:

  • Absence is the only way to say "not linked." A ref the caller's team does not link
    is left out of the answer map. It is never expressed by returning a verdict with
    team_owned=False. ConnectorAccess still defaults to False/False so that a bare
    ConnectorAccess() stays a shape the validator rejects, rather than quietly becoming a
    legitimate "not linked" answer.
  • can_edit is independent of linkage. A team can link a connector without granting
    edit rights on it. That is a complete answer on its own, not a partial one.

The answer is treated as an authorization input, not as user-facing data, so a malformed
answer raises instead of being normalized, coerced, or defaulted to empty. Both booleans
are checked by identity rather than truthiness, and connector ids are rejected when they
are boolbool subclasses int in Python, and a truthy value is never a legitimate
grant or a legitimate id.

Keys are validated as exact (str, int) pairs before membership is checked at all.
True, 1.0 and Decimal("1") all compare equal to 1, and ordinary tuple equality
carries that through, so ("mcp", True) would otherwise pass a membership check against
("mcp", 1). Since the keys of the answer are the question, a key that merely resembles
a requested ref has to fail loudly rather than be accepted as the connector it aliases.

Session safety

Hooks run on the endpoint's own live session. A hook whose own statement failed leaves
that transaction unusable on PostgreSQL, and a failed ORM flush leaves it unusable on
every backend — so every later statement in the request, including the ones a degradation
path needs to build its response, would be refused.

Hook invocation is therefore consolidated behind one internal door that rolls the session
back before the exception reaches the caller. Answer validation runs inside the same
guard, because a hook can poison the session without raising: run a statement that
fails, catch that itself, and return a shape this seam then rejects. In that case the
exception is the seam's own, so a guard placed around the call alone would not fire.

All five slots now go through that door, which is what makes the property hold for a slot
added to this module later without that slot's author having to know about it.

One shape stays uncovered on purpose: a hook that poisons the session, swallows its own
failure, and still returns a well-formed answer produces no exception anywhere, so nothing
triggers a restore. Closing that would mean probing session health after every hook call,
which is a different design than a failure path.

Test isolation

snapshot_connector_team_hooks saves every module-level slot and restores it on exit.
The slots are process-global, so a test that installs one and "cleans up" by calling the
setter with no arguments restores the empty state, not the state the test found — which
silently drops any hook the process had installed before it.

A discovery-based test enumerates every module global ending in _hook and asserts the
snapshot restores each one by identity. A slot added later and not added to the snapshot
fails the suite rather than failing silently.

Why merge this with no production callers

There are none, and the grep is honest about it:

$ grep -rn "ConnectorAccess" src | grep -v connector_team_scope.py
$ grep -rn "resolve_connector_access\|resolve_one_connector_access" src | grep -v connector_team_scope.py

Both return nothing. Merged on its own, this PR adds a hook that no route calls and no
in-tree code installs. With no hook installed every function here returns empty without
querying, so a standalone deployment sees no behavior change — but by the same token it
also gains no behavior.

The honest reason to merge it separately is review economics, not urgency. The routing
change that consumes this hook touches connector read and write paths where the failure
mode is a permission check that silently passes. Reviewing the shape of the answer, the
validation rules, and the session-failure handling at the same time as the call sites
means the contract questions and the routing questions compete for attention in one diff,
and the contract questions are the ones that are cheap to get wrong and expensive to
notice later. Landing the contract first makes the follow-up diff a question about call
sites against a contract already settled.

The cost is real and worth stating plainly: if the follow-up never lands, this is dead
code carrying its own test suite. It is a seam whose value is entirely in what is built on
it. Reviewers who would rather see the hook and its first consumer together are asking a
reasonable question, and the answer is a judgement call about review surface, not a
technical constraint.

The precedent is in the tree already: knowledge_base_team_scope is the same seam shape
for knowledge bases, including the same snapshot primitive, and this change also updates
that module's docstring where it says the connector seam has no snapshot counterpart —
which is no longer true.

Standalone xagent keeps MCP servers and custom APIs user-owned. The
connector team scope module already lets an application install hooks for
delete, rename and visibility; this adds the remaining slot, which answers
whether a caller's team links a given connector and whether it may edit
it.

The new access hook is batched: one call answers a whole collection of
(connector_type, connector_id) refs, so a caller never pays one hook call
per connector. A ref the caller's team does not link is expressed by
leaving it out of the answer map, never by a verdict carrying
team_owned=False, and the answer is shape-validated as an authorization
input -- a malformed answer raises rather than being coerced or defaulted
to empty.

Hook invocation is consolidated behind a single internal door so that a
hook which leaves a failed statement on the shared session has that
session rolled back before the exception reaches the caller. The
validation step runs inside the same guard, because a hook can poison the
session without raising: fail a statement, swallow that itself, and return
a shape this seam then rejects. All five slots now go through that door.

A snapshot context manager saves and restores every hook slot, so a test
that installs one restores whatever the process had rather than clearing
to empty. A discovery-based test enumerates the module's hook globals and
asserts the snapshot restores each by identity, so a slot added later that
is not snapshotted fails the suite.

With no hook installed the module returns empty answers without querying,
so a standalone deployment sees no behavior change.

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

Code Review

This pull request introduces a new ConnectorAccess hook to manage connector team ownership and edit permissions, centralizing hook invocation and session restoration through a new _call_connector_hook_gate function. The changes include robust validation for hook responses and comprehensive testing for session state management. Feedback identifies a potential resource leak in the test fixture's cleanup logic and suggests an optimization to avoid redundant normalization of connector references in the error-handling path.

Comment thread tests/web/services/test_connector_team_scope.py Outdated
Comment thread src/xagent/web/services/connector_team_scope.py Outdated
resolve_connector_access normalized its refs before checking whether an
access hook was installed, so a deployment with no hook installed could
still be made to raise by a malformed ref -- breaking the seam's first
invariant, that standalone xagent behaves identically when no hook is
installed. Decide the no-hook case first; the empty-refs check stays
after normalization, where it belongs, matching team_connector_ids,
which already returns before coercing its team_id.

Malformed refs still surface as the ValueError/TypeError they are rather
than as the seam's retryable 503: a ref that is not a (str, int-coercible)
pair is a defect in the calling route, not an outage of the installing
application, and converting it would point an operator at the wrong
system. resolve_connector_access_or_raise's docstring promised the
opposite; correct it to describe the boundary the code actually draws,
and correct its account of the warning log, which prints the normalized
tuples rather than the ones the caller passed in.

Cover both directions: malformed refs raise raw through the batch entry
point and the single-ref wrapper, well-formed refs plus a failing hook
still produce the one typed 503, and the no-hook path reads no ref at all.
Release the agent-team hook from a finally in the test file's reset scope,
so an exception inside a direct with block cannot leak it.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook seam (ConnectorAccess and ConnectorAccessHook) to resolve and validate team ownership and edit permissions for connectors in batches. It also unifies hook execution through a central gate (_call_connector_hook_gate) that automatically rolls back the database session upon hook failures or validation errors. Comprehensive unit tests are added to verify the validation rules, error handling, and session restoration. Feedback suggests refactoring the inline normalization of connector references in resolve_connector_access to reuse a shared normalization helper.

Comment thread src/xagent/web/services/connector_team_scope.py Outdated
The access seam normalized its ref batch inline in two places with a
byte-identical expression. The normalized set is not merely an argument:
it is also the baseline the hook's answer is validated against -- every
key the hook returns must be a member of it. Two separately written
normalizations could drift, leaving the question asked and the yardstick
its answer is measured by out of step with nothing failing to say so.
Naming the operation once makes that impossible structurally.

Behavior is unchanged. The call in resolve_connector_access stays below
the no-hook early return, so a deployment with no hook installed still
reads no ref at all; the call in resolve_connector_access_or_raise stays
outside the try, so a ref the caller built wrong still surfaces as the
TypeError/ValueError it is instead of the seam's retryable 503. The
normalizer documents that it deliberately does not tolerate an id it
cannot coerce -- dropping one would turn a caller's bug into a connector
the hook was never asked about -- and records that only the id half is
checked at run time: the connector type is carried by the annotation, so
a non-str type reaches the hook as passed in.

Also corrects a false explanation in the malformed-ref test: the outer
resolver normalizes before it consults the hook slot, so it raises with or
without a hook installed. The hook is installed there to give the trailing
"no hook call" assertion its meaning, not to keep the check from passing
vacuously.

Siblings: the two normalization sites in this module are the only ones;
grep for the expression across src/ and tests/ returns nothing else. The
int() coercion in web/services/connector_runtime.py builds a different
ConnectorRef dataclass from an ORM row rather than a caller-supplied
batch, and is left alone. knowledge_base_team_scope.py's resolver takes a
single object and has no ref batch to normalize. The single-ref test's
docstring next to the corrected one was checked line by line and makes no
false claim, so it is left unchanged.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook mechanism, including the ConnectorAccess dataclass and validation logic to determine team ownership and edit permissions for connectors. It also implements a unified hook invocation gate (_call_connector_hook_gate) that ensures database sessions are safely rolled back upon hook failure. Feedback on the changes highlights a potential TypeError when sorting requested refs in a logging statement if they contain uncomparable types, which could mask the original exception; using a fallback key like key=str is recommended.

Comment thread src/xagent/web/services/connector_team_scope.py Outdated
The failure arm logs sorted(requested), and the refs a caller hands in are
only type-checked statically. A mixed-type collection therefore reaches the
handler intact, where a bare sorted() raises TypeError from inside the except
block -- replacing the seam's one typed 503 with an untyped error and
discarding the hook failure that caused it. Sorting with key=str keeps the
arm total.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook mechanism to manage team ownership and edit permissions for connectors, along with a centralized gate function to handle hook execution and database session restoration on failure. Additionally, it updates the test suite to use a new snapshot context manager for cleaner hook teardown. The feedback suggests optimizing resolve_connector_access_or_raise to avoid redundant normalization overhead and double normalization on the happy path when no hook is installed.

Comment thread src/xagent/web/services/connector_team_scope.py
… about

resolve_connector_access_or_raise normalized refs before delegating, so a
deployment with no access hook installed still coerced every id and could
raise on a malformed one -- while resolve_connector_access, whose gate runs
before its own normalization, returned {} for that same input. The two
public entry points of one seam disagreed for the same refs.

Gate this entry point the same way, above the normalization that is itself
deliberately above the try, so a standalone deployment reads no ref at all
through either door. The normalization stays outside the try, so an
un-coercible id still surfaces raw once a hook is installed.

The docstring paragraph describing that boundary was true only of the id
half of a ref and only of a deployment with a hook installed; it is rewritten
into three paragraphs that each hold: the no-hook short circuit, the raw-error
boundary for un-coercible ids, and the connector type, which the annotation
carries and nothing re-checks at run time.

Sibling sites: resolve_connector_access already had the gate; the
team-visibility slot reaches the same decision through
team_connector_hook_installed(). Those are the only hook slots in the module.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new ConnectorAccess hook mechanism to manage and validate team-level access and edit permissions for connectors, including validation helpers, session recovery logic, and a batch resolver. Comprehensive unit tests have been added to verify the new hook, validation rules, and session restoration behavior. The review feedback suggests a more idiomatic and concise type check for the can_edit field using isinstance.

Comment thread src/xagent/web/services/connector_team_scope.py Outdated
…dentity tests

`_validate_connector_access_answer` required `can_edit` to be exactly
`True` or `False` by testing both identities. `isinstance(verdict.can_edit,
bool)` states the same rule directly: `bool` cannot be subclassed and has
exactly two instances, so the two forms accept and reject the same values.
Probed over True/False/1/0/1.0/"x"/None/Decimal("1") -- identical verdicts
on all eight.

It also makes the intent readable next to the line above it. The two
adjacent checks look inconsistent but are not: `team_owned` admits one
specific value and stays an identity check, while `can_edit` admits either
bool and is a type check. The docstring now says that instead of covering
both under one "identity check, not a truthiness check" phrase, which
described only half the pair.

The raised message is unchanged on purpose: it is what tells a hook author
why `1` is refused, and dropping that sentence would remove the only place
the bool-is-a-subclass-of-int reason is stated to the caller.

Sibling sites in this module, all enumerated:
- `_validate_connector_access_answer:379` `team_owned is not True` -- left
  as is. `isinstance(..., bool)` would accept `team_owned=False`, which is
  the exact answer shape this line exists to reject.
- `:360` connector id and `:228` team-member id already use
  `isinstance(x, bool) or not isinstance(x, int)`; unchanged, and this edit
  makes the module use one idiom for type checks throughout.
- `git grep "is not True and"` over `src/` returns no other occurrence, so
  there is no fourth site.

Behavior is unchanged, so no test is added; the existing pins already cover
both directions -- `test_resolve_connector_access_rejects_a_can_edit_that_is_not_exactly_bool`
("false", 1, 0) and `test_resolve_connector_access_accepts_linked_but_not_editable`
(can_edit=False).
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook (_connector_access_hook) along with the ConnectorAccess dataclass to manage team-level access and edit permissions for connectors. It adds robust validation, normalization, and error-handling wrappers, including a unified hook execution gate (_call_connector_hook_gate) that ensures database session rollbacks on failures. The feedback highlights a performance improvement opportunity to avoid redundant normalization of connector references by extracting the core logic of resolve_connector_access into an internal helper that accepts pre-normalized references.

Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py Outdated
…tion

`resolve_connector_access_or_raise` normalized `refs` above its `try`, then
called `resolve_connector_access`, which normalized the result a second
time. Measured before the change, with one call carrying
`[("mcp", "5"), ("mcp", 5)]`: the raising entry point invoked
`_normalize_connector_refs` twice, the second time over the already
normalized and deduplicated one-element set. Idempotent and never larger
than the first pass, but work no caller asked for.

Both entry points now normalize once and hand the result to a shared
`_resolve_normalized_connector_access`. Same measurement after the change:
one invocation through either entry point.

Normalization stays where it was, above the `try` in the raising entry
point. That boundary is what makes an un-coercible id surface as the
`TypeError`/`ValueError` it is instead of a retryable 503, and the eight
`lets_a_malformed_ref_raise_raw` cases pin it; passing raw `refs` down
would have moved it.

The hook is passed to the helper rather than read from the module global
inside it. Each entry point reads the slot once, checks it for `None`, and
passes down what it read, so the helper cannot act on a hook other than the
one that was just cleared, and a future third caller does not inherit an
unguarded read. This also removes the second global read the non-raising
entry point used to do.

Two pieces of prose stopped being true and were rewritten rather than left:
- The raising entry point's docstring named `resolve_connector_access` as
  the source of the answer validation whose `ConnectorRuntimeError` passes
  through. That validation lives in `_validate_connector_access_answer`,
  which is now what the sentence names.
- The comment on its hook gate said the gate was "not redundant with the
  identical gate inside resolve_connector_access". There is no longer a
  duplicated gate to be redundant with, so the comment states the invariant
  it actually guards: both entry points must answer `{}` for the same input
  in a deployment with no hook installed, and that answer comes before
  normalization.

Observable behaviour is unchanged, so no existing test can see this. The
new `test_each_entry_point_normalizes_the_refs_exactly_once` counts the
invocations directly, parametrized over both entry points.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook seam, including the ConnectorAccess dataclass, batch and single-ref resolution functions (resolve_connector_access_or_raise), and a unified hook gate (_call_connector_hook_gate) that ensures automatic database session rollback on hook failures. It also adds a context manager for snapshotting hooks in tests and updates existing hooks to use the new gate. Feedback on these changes suggests strictly validating the input reference in resolve_one_connector_access_or_raise to prevent silent coercion of malformed IDs, and explicitly verifying that connector_type is a valid value (such as 'mcp' or 'custom_api') during answer validation.

Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py
`_normalize_connector_refs` coerced every id with `int()`. For an id that
was not already an int but would convert, that quietly changed which
connector was asked about: `("mcp", "11")` reached the hook as
`("mcp", 11)`, and the answer came back keyed `("mcp", 11)` while the
caller still held `("mcp", "11")`.

`resolve_one_connector_access_or_raise` unwraps that answer with
`.get(ref)`, so the lookup missed and it returned `None` -- which this
seam defines as "the caller's team does not link this connector", and
which its own docstring promises is "never a failure" -- for a connector
the hook had just granted. Probed at the parent commit against a hook
that grants every ref it is asked about:

    ref passed in    batch answer keys   wrapper returned
    ('mcp', 11)      [('mcp', 11)]       granted
    ('mcp', '11')    [('mcp', 11)]       None        <- lost
    ('mcp', True)    [('mcp', 1)]        granted, for connector 1

Ids are now validated instead of coerced, so the ref the hook is asked
about is always the ref the caller passed and an answer key can never
fail to match the ref it answers. Both bad rows above now raise
`TypeError` at both entry points, before the hook is reached. The same
probe after this commit returns `raised TypeError` for both.

`bool` is excluded explicitly, matching `_validate_connector_access_answer`
and `_validate_team_connector_answer`: `isinstance(True, int)` is `True`,
and `("mcp", True)` hashes equal to `("mcp", 1)`, so the third row above
was resolving a different connector's access with nothing failing to say
so.

Both new shapes join `MALFORMED_REFS` rather than getting a new error
category, so the three existing families that parametrize over it --
raise-raw through the batch entry point, raise-raw through the single-ref
wrapper, and returns-{} when no hook is installed -- cover them without
changing what any of those tests assert.

Prose corrected where the old semantics had been written down:
- `_normalize_connector_refs` said it coerced, and said an id that "will
  not coerce" is the rejected case. It now says an id that is not already
  an int is rejected, and records why coercing was worse than either
  coercing or dropping.
- `resolve_connector_access` never stated what its answer is keyed on --
  the omission this defect grew in. It now states that the keys are the
  caller's own refs, and why that is now guaranteed.
- `resolve_connector_access_or_raise` documented its logged refs as
  "the normalized `(connector_type, int(id))` tuples ... `('mcp', '5')`
  in, `('mcp', 5)` logged". That example no longer happens; it now says
  the logged refs are the caller's own.
- The malformed-ref tests described their subject as "not a
  `(str, int-coercible)` pair".

One existing test changed payload: the normalize-once check passed
`[("mcp", "5"), ("mcp", 5)]`, which the seam now rejects. It uses a
duplicated valid ref instead, since it counts passes rather than
exercising what a pass does.

Connector type handling is unchanged: still `isinstance(..., str)` only,
still not checked against the `ConnectorType` literal at run time.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

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

Code Review

This pull request introduces a new connector access hook seam (ConnectorAccessHook) and the ConnectorAccess dataclass to manage and validate team-level connector access and edit permissions in a batched manner. It also refactors existing hook invocations to route through a unified gate (_call_connector_hook_gate) that handles automatic database session restoration on failures. Comprehensive unit tests are added to verify the validation logic, error handling, and session rollback behavior. Feedback suggests adding validation to the visible_team_connector_ids hook call to ensure malformed answers are caught early, aligning it with other validated hooks.

Comment thread src/xagent/web/services/connector_team_scope.py

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Summary

This PR adds the missing "access" slot to the connector_team_scope hook seam — letting a downstream multi-tenant app answer whether a caller's team may view/edit a given connector — alongside a typed 503 failure contract and a session-rollback guard for hook failures. Nothing in this PR is wired to a live route yet: the consuming route is deferred to a follow-up PR, so all new code (including the 503 contract and rollback gate) is currently exercised only by this PR's own test suite. The seam shape and batching contract are sound and consistent with the existing knowledge_base_team_scope precedent; the issues below are mostly unused-surface-area and simplification opportunities rather than defects.

Blocking: no — recommended event: APPROVE


Round 0 — Design Verdict: Over-engineered

The seam shape and batching contract are right, but two of the PR's three headline investments — the typed-503 failure contract and the session-rollback "door" — have no reachable beneficiary at any route today, since this PR wires no caller. The new ConnectorRef = tuple[ConnectorType, int] alias also duplicates the name (but not the validation) of an existing, better-validated ConnectorRef dataclass already used pervasively elsewhere in the same package. None of this is wrong in direction — the gap to a lean version is mostly deletion/reuse of what already exists, not a redesign.


Findings by Severity

MAJOR

1. 503 failure contract never reaches a real client (cross-cutting: src/xagent/web/app.py, src/xagent/web/api/mcp.py, src/xagent/web/api/custom_api.py, src/xagent/web/services/connector_team_scope.py)

app.py registers no exception handler for ConnectorRuntimeError; the four routes that would consume a hook failure via this mechanism (mcp.py's update_mcp_server/delete_mcp_server, custom_api.py's update_custom_api/delete_custom_api) either catch broad except Exception and re-raise a generic HTTPException(500), discarding .status_code, or have no try/except at all and fall through to the global catch-all → generic 500. This is pre-existing (those routers are untouched by this diff) and already applies to the live delete/rename hooks today, so it does not meet the PR-caused blocking bar — but this PR adds an access hook and a whole test suite asserting status_code == 503 on a contract that doesn't reach any real caller, which is worth tracking.

Blocking: no — pre-existing, not introduced by this PR's diff. Suggested follow-up: register a ConnectorRuntimeError handler in app.py, or have mcp.py/custom_api.py explicitly catch-and-translate it ahead of their broad except Exception.

MINOR

2. src/xagent/web/services/connector_team_scope.py:68ConnectorRef = tuple[ConnectorType, int] duplicates the name of the existing @dataclass(frozen=True, order=True) class ConnectorRef in src/xagent/core/tools/adapters/vibe/connector_runtime.py (already imported from in this file, and used as a dict key throughout src/xagent/web/services/connector_runtime.py). _normalize_connector_refs (line ~269) re-implements validation the dataclass's __post_init__ already provides; a caller passing an actual connector_runtime.ConnectorRef instance would get a raw TypeError instead of this seam's typed error. No live impact today (zero production callers), but worth adopting the existing dataclass or renaming this alias before the follow-up PR wires in real callers. Blocking: no — no production callers exist yet.

3. src/xagent/web/services/connector_team_scope.py:538_call_connector_hook_gate(db, hook, *args, validate=None, **kwargs) erases static arity/type/keyword checking at all 5 hook-invocation call sites, including the keyword-only team_id guarantee that TeamConnectorVisibilityHook's Protocol was specifically designed to enforce. All 5 current sites are correct, so this is not a live bug, but a @contextmanager-based guard would preserve static checking at call sites without losing the "one door" property. Blocking: no — defensible centralization tradeoff, no current call-site defect.

4. src/xagent/web/services/connector_team_scope.py:504_restore_session_after_hook_failure's docstring claims rollback happens "after the route's own db.commit()," but at both rename call sites (mcp.py:3369 before db.commit() at :3373; custom_api.py:393 before db.commit() at :406) the hook actually runs pre-commit with route-side mutations still pending. This causes no live data-loss regression since both routes already self-protect independently. Separately, the gate's unconditional db.rollback() is stylistically inconsistent with release_db_connection_if_clean (src/xagent/web/models/database.py:105-137), which conditionally refuses to roll back a dirty session — worth a short comment noting why unconditional rollback is safe here (the request is aborting regardless). Blocking: no — docstring-accuracy issue only, no behavior change.

5. Test-coverage precision gaps (tests/web/services/test_connector_team_scope.py, tests/web/test_team_sharing_hooks.py) — several small gaps, none blocking: (a) the access hook's "hook raises directly" path is tested only with db=None, never against a real SQLAlchemy session, unlike the delete/rename/visibility hooks which each have a dedicated real-session raising-path test; (b) the malformed-answer-vs-hook-DB-failure tests for resolve_connector_access_or_raise never assert on exc.__cause__ to prove the two failure modes stay distinguishable, unlike the sibling resolve_team_connector_ids_or_raise tests; (c) _restore_session_after_hook_failure's getattr(db, "rollback", None) silently no-ops for anything lacking .rollback, so no test can prove real rollback semantics beyond the real-session tests noted above; (d) test_connector_team_scope.py:835 uses with pytest.raises(Exception):, broad enough to mask a broken test setup — recommend narrowing to pytest.raises(SQLAlchemyError); (e) test_team_sharing_hooks.py's connector-hooks teardown (line 70) still uses set_connector_team_hooks() (clear-all) rather than this PR's own snapshot_connector_team_hooks() primitive — exactly the test-isolation hazard that primitive exists to prevent — while the KB half of the same file uses the snapshot pattern a few lines away. Blocking: no — test-precision only, no coverage regression.

6. Documentation-precision items (src/xagent/web/services/connector_team_scope.py, multiple docstrings) — (a) the rationale for rejecting bool/float/Decimal connector-ids in answer keys (near line 269) claims it prevents "resolving a different connector's access," but Python dict/tuple equality means ("mcp", True) and ("mcp", 1) hash identically, and all consumption in this codebase is .get()-based, so misattribution can't actually occur — the rejection is still reasonable as type-contract hygiene, just not for the stated reason; (b) no connector_access_hook_installed() predicate exists, unlike the sibling team_connector_hook_installed(), so a future caller can't distinguish "no hook installed" from "hook installed, answered empty"; (c) ConnectorAccessHook's type alias has no docstring noting hooks receive a deduplicated, unordered frozenset of refs, not a list; (d) two docstring paragraphs are near-duplicated across function pairs (resolve_team_connector_ids_or_raise/resolve_connector_access_or_raise's "session restore" block; visible_team_connector_ids/resolve_connector_access's "nothing cross-checks them" block) and could be stated once; (e) _normalize_connector_refs's docstring narrates an intra-PR-only design reversal (the int("11") coercion added then removed within this PR's own commit history) in detail that will read as permanent documentation post-merge, even though commit 1d4a25ef's message already captures it — consider trimming to the final rationale only. Blocking: no — documentation-only, no code or behavior implication.


Simplification Opportunities

  • delete: ConnectorAccess.team_owned (line 64) carries zero information — presence in the answer map already means "linked," so the field can only ever be True, and it's read nowhere except its own validator. Drop it; make can_edit: bool the sole required field with no default, so a bare ConnectorAccess() fails at construction instead of via a runtime validator.
  • yagni: resolve_connector_access (line 456, non-raising batch function) has zero production callers and isn't even called internally by resolve_connector_access_or_raise; resolve_one_connector_access_or_raise likewise has zero callers anywhere. Make private, or defer the public shape until the follow-up PR's actual call pattern justifies it.
  • shrink: snapshot_connector_team_hooks (line 751) hardcodes a 5-tuple of hook-slot names with a manual-sync caveat. test_connector_hook_slot_names_are_discoverable nearby already discovers slots by enumerating module globals ending in _hook — reuse that discovery approach internally to remove the caveat and its tripwire test.
  • shrink: Three near-identical tests in test_connector_team_scope.py (lines 331, 343, 357: rejects a non-tuple key / wrong-length key / non-str connector_type) share identical structure, differing only in the bad key and expected message. Collapse into one @pytest.mark.parametrize test, matching the pattern already used elsewhere in this file.

net: roughly -45 to -60 lines possible (largest share from the team_owned field removal, once ~20 test call sites and one dedicated rejection test are updated).


Re-review: Prior Findings (gemini-code-assist bot)

All 7 canonical prior findings are resolved as of current HEAD (1d4a25ef), independently re-verified against the code rather than taken on the author's word:

# Finding Status
1 Test fixture cleanup exception-safety (try/finally around hook reset) FIXED (53c36e16)
2 Redundant re-normalization of connector refs FIXED (7e4307e6)
3 sorted() could raise TypeError on uncomparable ref types, masking real exception FIXED (4b6bd01f)
4 can_edit bool check should use isinstance not manual identity checks FIXED (034b3c6a)
5 Silent int() coercion let bool ids alias, causing "granted" to misreport as "not linked" FIXED (1d4a25ef, current HEAD)
6 connector_type not validated against {"mcp","custom_api"} in answer validator WAIVED — non-str types are checked; invalid-but-str literals are caught by CI's mypy Literal enforcement; duplicating the set would risk drift
7 visible_team_connector_ids's hook-gate call omits validate= WAIVED — pre-existing gap predating this PR's base commit, mechanical wrap only, tracked in issue #1878

No regressions found on any previously-fixed item.


Non-blocking context (FYI, not a defect in this PR)

The PR's cited precedent, knowledge_base_team_scope.py (touched by this PR only via a docstring edit, no behavior change), has neither of this PR's two headline safety investments: its access hook is invoked with no session-rollback gate against ~14 live endpoints' real sessions, and KnowledgeBaseAccess defaults can_edit=True, can_delete=True (fail-open), unlike the new ConnectorAccess's fail-closed False/False defaults. Out of scope for this PR, but worth a tracked follow-up since it means the more valuable half of "session safety" work sits on the less-exposed of the two twin seams.


Blocking Status & Recommended Decision

Blocking: no — recommended event: APPROVE. Findings above are all minor/non-blocking or already resolved; item 1 (the 503 contract not reaching any real caller) is a real, worth-tracking issue but predates this PR's diff and doesn't meet the PR-caused blocking bar.

Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread tests/web/services/test_connector_team_scope.py Outdated
Comment thread tests/web/test_team_sharing_hooks.py
Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread src/xagent/web/services/connector_team_scope.py
Comment thread tests/web/services/test_connector_team_scope.py Outdated
…re docstring

The session-restore docstring said the rollback happens after the route's
own commit "on the post-commit decoration paths". No call site works that
way: both rename paths and both delete paths invoke their hook before the
route commits. State what actually holds instead -- the rollback can only
reach the aborting request's own pending mutations, which the re-raised
exception was going to strand anyway -- and record why the rollback is
unconditional here, unlike release_db_connection_if_clean, which runs on
the success path and must not discard work the request means to commit.

Test side:
- the hook-door restore test asserted pytest.raises(Exception), broad
  enough to pass on a TypeError from a mis-built invoke; narrow it to
  SQLAlchemyError, which is what the primary-key collision raises.
- the connector half of the team-sharing hook test reset its slots by
  clearing all of them, while the knowledge-base half of the same file
  already used the snapshot primitive. Use the snapshot there too, and
  keep the clear-all call inside the block where it is the assertion
  rather than the teardown.
- collapse three structurally-malformed-answer-key tests into one
  parametrized case.

Sibling enumeration: roughly two dozen older suites elsewhere under
tests/ still reset connector hooks by clearing them. They predate this
change and are a separate cleanup, so a comment claiming this file was
the last one doing so is corrected rather than acted on.
…auses

The access wrapper's raising-hook tests all passed None for the session, so
nothing covered the restore on a real one -- unlike the team-visibility
wrapper, which has that test. Add the sister case, and assert the typed
error's cause on both access failure modes so a hook that raises stays
distinguishable from an answer the seam rejects.
@AlexLiu190625

AlexLiu190625 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Head is now 9ced8aac9. Two commits since you looked, both scoped to the points you raised:

  • 0f19cebcf — corrected _restore_session_after_hook_failure's docstring (all four hook call sites run before their route's db.commit(), so the old "post-commit decoration paths" qualifier named an empty set) and added the note on why the rollback is unconditional here while release_db_connection_if_clean refuses a dirty session; narrowed the broad pytest.raises(Exception) to SQLAlchemyError; moved test_team_sharing_hooks.py's connector teardown onto snapshot_connector_team_hooks; collapsed the three malformed-key tests into one parametrized case.
  • 9ced8aac9 — added the access wrapper's real-session raising-hook test and asserted __cause__ on both access failure modes.

Answering the items from the review body that have no inline thread of their own.

The typed 503 never reaching a client — filed as #2055. It is real: app.py registers no handler for ConnectorRuntimeError, and the connector CRUD routes either catch broad Exception and re-raise HTTPException(500) or have no handler at all, so .status_code is dropped either way. It sits outside this change because fixing it means editing app.py and the route handlers, none of which this branch touches — the seam can only produce the typed error, not decide how the application surfaces it.

The knowledge-base twin's fail-open defaults and missing rollback gate — filed as #2056. Confirmed at knowledge_base_team_scope.py:50-52: KnowledgeBaseAccess defaults can_edit=True, can_delete=True, and its hook invocation has no session-restore gate, across a much larger live endpoint surface than this seam's. This branch touches that file only for a docstring, and closing the gap there means changing behaviour on live endpoints, which is its own change with its own blast radius.

resolve_connector_access and resolve_one_connector_access_or_raise having no callers — keeping them public. Every symbol the access slot adds has zero production callers by design: this change deliberately wires no route, so that the seam and the policy that uses it land separately and each can be reviewed on its own. Privatising on that basis would mean un-privatising in the change that adds the first caller, and the pair is the seam's public shape — the batch form is what a route with several connectors calls, the single form is the convenience over it.

No connector_access_hook_installed() predicate — leaving it to the change that adds the first caller. The distinction it would draw, "no hook installed" versus "hook installed, answered empty", only matters once something branches on it, and that caller is what says which of the two it needs; team_connector_hook_installed() exists because a caller asked for it.

The remaining docstring items — the ConnectorAccessHook alias note and the two near-duplicated paragraphs are fair, and are the kind of edit better made where the seam is next touched than as a separate pass over an otherwise unchanged file. On _normalize_connector_refs' history paragraph: it is kept on purpose. It does not exist to record what happened, it records why coercion must not be re-added — the failure it describes (a ref asked about under one key and answered under another, read by the caller as "the team does not link it") is not obvious from the current code, and a future reader looking at a rejection where a coercion would be more convenient is exactly who needs it.

Rollback semantics being unprovable through getattr(db, "rollback", None) — the new test closes that: it drives a real ORM flush failure on a real session through the access wrapper and then asserts the session still answers select(1), which fails with PendingRollbackError if the restore is removed.


Edit: the two commits above were re-recorded as 0f19cebcf and 9ced8aac9 to fix their commit metadata; the tree is unchanged (git diff between the old and new heads is empty). Commit ids in this comment and in the inline replies have been updated to match.

@AlexLiu190625
AlexLiu190625 force-pushed the feat/connector-access-seam branch from 93ea597 to 9ced8aa Compare September 2, 2026 17:06
@AlexLiu190625
AlexLiu190625 added this pull request to the merge queue Sep 2, 2026
Merged via the queue into xorbitsai:main with commit a31bbaa Sep 2, 2026
25 of 27 checks passed
AlexLiu190625 added a commit to AlexLiu190625/xagent that referenced this pull request Sep 3, 2026
main's xorbitsai#1912 added resolve_connector_access_or_raise() to
connector_team_scope.py, whose generic-exception fallback arm raises
ConnectorRuntimeError with reason "connector_access_resolution_failed".
This PR's whitelist-coverage test requires every reason a
ConnectorRuntimeError construction can produce to be classified in the
same change that adds the raise site, so merging main's new raise site
into this branch turned that test red until the reason is classified
here.

Judged against the two questions this whitelist's comment asks: does
the reason name who owns the task, or state what an authorization
check concluded? Neither. It only reports that resolving connector
access failed, the same as the already-whitelisted
team_scope_resolution_failed a few lines above -- same module, same
503, same fallback-arm shape.
yiboyasss pushed a commit to yiboyasss/xagent that referenced this pull request Sep 4, 2026
…itsai#1919)

* feat(web): project connector runtime failures onto a wire-safe error

A ConnectorRuntimeError carries a curated, public-safe message and a
details payload, but nothing today turns either into something a chat
client can read. Add the two functions that do, plus the type that owns
what is allowed onto the wire.

connector_runtime_client_message adapts the exception's safe_message and
falls back to the fixed task-failure text for anything else, so the
boundary stays fail-closed even if a future caller passes an incidental
exception despite the function's specific name.

connector_runtime_public_error projects the exception onto
(code, PublicErrorDetails). PublicErrorDetails holds a single field and
normalizes it in __post_init__: a reason that is neither a listed enum
value nor "<listed prefix>.<declared key name>" becomes None. Putting the
whitelist in the constructor rather than in the projector makes
"constructing this type" and "passing the whitelist" the same act, so a
direct construction from another module cannot carry free text. It nulls
rather than raises because every construction site is on the reporting
path of an already-failed task.

The type has no connector_ref field. The sink for this projection is
broadcast_to_task, whose audience includes anonymous widget and
share-link visitors, and the same judgement keeps two runtime reasons off
the whitelist: runtime_task_identity_mismatch and runtime_owner_mismatch
state the task's ownership and the outcome of an authorization check.
The four 503 reasons that are listed only state that a server-side
component is unavailable. The question that decides this is written into
the class docstring so a later addition has to answer it too.

Tests derive the reason surface by AST-scanning src/ for every site that
constructs a ConnectorRuntimeError -- both construction forms -- rather
than from a list of modules, and assert the whitelist neither misses a
raise site nor grows an entry nothing produces.

* feat(web): carry an error code on the terminal task_error frame

create_terminal_task_error_event gains two keyword-only parameters. They
are written into the frame only when both are supplied, so the four call
sites that pass neither produce a byte-identical frame.

The details parameter is annotated PublicErrorDetails, which makes mypy
refuse a dict at every call site it can see. That is not the whole door:
annotations are not enforced at run time, and a caller routing through
Any -- a dict decoded from JSON, a **kwargs splat -- type-checks clean
and would only fail deep inside the function on a missing to_wire. The
first statement in the body names the contract instead.

That check reads `type(details) is not PublicErrorDetails` rather than
isinstance. A frozen dataclass can be subclassed, and a subclass that
overrides to_wire without reading self.reason satisfies both isinstance
and mypy while bypassing the whitelist that lives in __post_init__. Only
the class itself carries that guarantee.

The client-safe AST guard is untouched: the new parameters are
keyword-only so the message argument does not move, the new fields are
not in the guard's sensitive-field set, and no producer or error-payload
sink is added. Both exact baselines still hold at 30 and 52.

* feat(web): classify connector runtime failures at terminal settlement

A ConnectorRuntimeError subclasses RuntimeError and is not a
RequiredMCPUnavailableError, so the terminal settlement's two-way
classification sent it down the else branch and every such failure
reached the user as "Task execution failed." -- which the chat client
then rendered as an unknown error. The exception's own docstring already
commits its message and details to being safe for API callers.

Add a third branch between the two existing ones; neither of them
changes. It settles with the exception's string, marks the history row
client-safe, and broadcasts the curated sentence along with the
projected code and details.

It also logs one structured record naming the code, the reason and the
connector. That log reads the raw exc.details rather than the projection:
its audience is operators, the connector identity is what makes the
record actionable, and it never leaves the server. The broadcast frame
carries neither the connector identity nor any reason the whitelist
dropped. The log is unconditional -- an observability record that can be
switched off is one that is not there when it is needed -- and all three
values are short bounded strings.

* fix(frontend): render the terminal error bubble and name the missing key

The error and task_error branches dispatched an assistant message without
isResult. The conversation panel renders only user, isResult and
system-notice messages, so the bubble was filtered out entirely and the
turn fell back to a virtual "unknown error" placeholder until the page
reloaded -- the server could say whatever it liked and none of it was
shown. The task_completed branch already fixed this the same way; copy
that precedent, comment included.

Read the frame's code and details. When the code is one of the three that
mean a connector is missing a value the user can supply, replace the
relayed sentence with wording that names the key, parsed off the reason's
last segment. A reason that names no key -- a bare enum value, or one the
server whitelist dropped -- gets the keyless wording, and
connector_runtime_unavailable keeps the plain failure wording because it
reports a component being down, which the user cannot act on.

Both are i18n keys with en and zh entries, not hardcoded English.

The pair is also kept in a new lastConnectorRuntimeError state field. It
holds only what the frame is allowed to carry: the frame has no connector
identity, deliberately, because its audience includes anonymous widget
and share-link visitors. Anything more specific has to come from the
owner-only per-task requirements endpoint.

* refactor(web): tie each public reason to its own raise site

Two follow-ups on the reason whitelist, both narrowing it.

The whitelist no longer lists payload_too_large or encryption_unavailable.
Both belong to the runtime value-fill endpoint, which does not exist yet;
they were listed ahead of it because the whitelist is the contract that
endpoint will be written against. That required an exemption in the test
asserting no listed reason is unproducible, and an exemption on that
assertion is an allowance with no expiry date -- by the time the raising
code lands, the audience judgement behind the entry has to be
reconstructed from scratch. Each string now arrives with the site that
raises it, and the assertion holds with no exemptions at all.

The key half of a prefixed reason is now matched against
RUNTIME_SOURCE_KEY_RE, the grammar the connector runtime already
exports, rather than a second copy of the same pattern compiled here.
The module already imports from that file, so there is no new coupling,
and the two definitions can no longer drift apart.

* docs(web): describe what client_error_messages now holds

The module docstring described it as fixed fallback strings, which was
accurate when the file held two constants and one adapter. It now also
owns PublicErrorDetails and the reason allowlist governing what may ride
on a task_error frame, so say so.

* fix(web): degrade instead of raising on the terminal error path

Three changes to the terminal frame, all in the same direction: this
frame is what stands between a failed task and a silent one, so nothing
about it should be able to cost the frame itself.

The details type check no longer raises. PublicErrorDetails already nulls
an unlisted reason rather than raising, and its docstring gives the
reason -- every construction site is on the reporting path of an
already-failed task. The frame builder sits on that same path, and the
one call site passing these arguments evaluates them inside the
`except Exception` that only logs "its terminal broadcast failed". A
TypeError there meant the task committed FAILED and the client saw
nothing at all, which is the exact failure this branch exists to remove:
the strict half degraded to a worse outcome than the bug. A rejected
value is now dropped, logged at ERROR with its stack, and the frame goes
out without it.

`code` now passes the same closed set. ConnectorRuntimeError types it as
a bare str and assigns it unvalidated, so "only the ten module constants
reach here" describes today's raise sites rather than anything the code
enforces -- one door locked and the one beside it open. V1ErrorCode is
the repository's existing closed set of client-visible codes and carries
all ten; it is imported rather than recopied, inside the function because
the v1 package's __init__ pulls in routers that import this module.
Unknown values follow the same drop-and-log path.

The durable half of the settlement is now pinned too. The classification
branch writes three things and the tests covered one: deleting
`client_history_message_type = CLIENT_SAFE_FAILURE_MESSAGE_TYPE` left
every test in the file green while a reloading user dropped back to the
generic failure text the live bubble no longer shows. The capture helper
hands back the settlement kwargs, and both the curated and the incidental
branch assert what they persist.

* docs(web): name the /v1 sibling projection and why it differs

`_raise_v1_connector_runtime_error` (web/api/v1/tasks.py) already
projects this same exception for a client, and it makes the opposite call
on both halves: it maps the code through V1ErrorCode with an unknown
fallback, and ships `to_public_error()["details"]` whole with
`connector_ref` in it. Reading either projector alone, the other looks
like a contradiction.

It is not: the audiences differ. /v1 answers an API key held by a caller
already authorized for the task. This path feeds `broadcast_to_task`,
which reaches every connection under the task id, anonymous widget and
share-link visitors included -- the fact every other choice here follows
from. Say so where someone comparing the two will be standing, and say
why they stay two projectors rather than one taking the audience as an
argument: output width behind a caller-supplied flag fails open the first
time the flag is passed wrong.

Also states why the details-shape check exists at all, since __init__
normalizes that attribute: it is a plain public attribute anything can
reassign afterwards, and this is the last step before the wire.

* fix(frontend): dedup terminal errors by code and reason, drop unread state

The dedup key was the server's own sentence, and `_message_for_code`
returns one fixed string per error code that does not vary with the
missing key. So two turns failing on two different keys inside the
30-second window collapsed into one bubble, and the survivor named
whichever key failed first -- one value standing for two facts, the same
shape this change set exists to remove, reintroduced a layer up. The
comment added alongside it claimed the server message "identifies the
failure", which stopped being true the moment the rendered wording began
deriving from `reason` instead.

`isDuplicateMessage` already takes an `occurrenceIdentity` argument for
exactly this. Pass the code and reason. Two tests cover both directions:
different keys keep both bubbles, a genuine repeat still collapses.

Also drops `lastConnectorRuntimeError`. It was written on every terminal
error frame and read by nothing -- the dialog that consumes it belongs to
a later PR in this series, so it was a surface with no consumer in the PR
that introduced it. The state field, its action type, its reducer case,
its TASK_SCOPED_ACTION_TYPES entry and the test probe all go with it.

The helper that reads the pair off the frame is renamed
`getTaskErrorProjection`: it returns for any frame carrying a code, and
the old name promised a connector-specific answer it never checked for.
Whether a frame is connector-related is decided by the code, at the one
place that asks.

* fix(web): drop owner key names from the connector-runtime reason whitelist

A reason of shape missing_context.<key> carried the connector owner's own
declared field name onto task_error, whose broadcast reaches anonymous
widget and share-link visitors. Collapse _is_public_reason to a plain
membership check against the fixed-string allowlist and drop the prefix
set entirely -- the other five prefixes had no raise site in src/ either.

missing_context.<key> is now dropped whole rather than trimmed to its
prefix, so details comes back {} and code alone survives. The other two
missing-value codes are unaffected: their reasons are the fixed strings
not_provided/store_lost, already on the allowlist.

Two tests that used to compare a PublicErrorDetails instance against
another instance built from the same withheld reason went quietly blind:
both sides null out under __post_init__ and the equality still holds.
Rewritten to assert through to_wire() instead. Also adds an AST-derived
pattern check so a re-added prefix form is caught by shape, not just by
its literal value.

* fix(frontend): drop the missing-key wording, match the server's dropped reason

The server no longer sends a reason built from the connector owner's
declared field name (previous commit), so the client-side key parser
has nothing left to read. Drop missingRuntimeKeyFromReason and the
keyed translation string, and always use the keyless
connectorRuntimeMissing wording for the three missing-value codes.

Also switches this test file's i18n mock to the variable-aware form
already used across 23 other test files (t returns `key:JSON(vars)`
when vars are passed). The old key-only mock could not tell an
interpolated call from a bare one, so a future regression that starts
passing the key back into the wording would go undetected here.

The two dedup tests that asserted on the now-removed keyed wording are
dropped; their (code, reason) replacements land separately.

* fix(frontend): dedup terminal errors on (code, reason), not the fixed sentence

The prior comment on errorOccurrenceIdentity argued from a scenario
that never occurs on this path: two different missing-value codes
sharing one dedup key. Each of the three connector-runtime codes maps
to its own fixed server sentence, so two different codes never share
a key in the first place.

The scenario the (code, reason) identity actually guards is one code
failing under two different admitted reasons -- a runtime secret that
was never provided, then later found lost from its store. Both share
the same server sentence and therefore the same base dedup key, and
without the reason folded in, the second turn's result bubble would
vanish inside the existing dedup window.

Replaces the two dedup tests with that scenario and its inverse (a
genuine repeat of one failure still collapses to one bubble).

* fix(frontend): only flag the terminal task_error frame as a turn's result

The shared "error" / "task_error" handler flagged every bubble it
produced as isResult, but the root "error" type is a mixed channel: a
rejected chat message, a rejected pause, or a rejected resume all
arrive on it while the viewed task is still RUNNING or
WAITING_FOR_USER. Flagging one of those closes the conversation
panel's live progress indicator and waiting-answer form for a turn
that has not actually ended, and drains the turn's accumulated trace
events into the rejection bubble.

task_error has no such ambiguity: every frame of that type is emitted
only after the row has been committed FAILED (task_orchestrator.py's
settled branch, and websocket.py's legacy only_if_running helper,
which does not broadcast when its update matches no row), and it is
also the only frame carrying the structured code/details pair used by
the connector-runtime wording and the dedup identity. Gate both on the
frame type instead of treating every "error"/"task_error" frame alike.

Replaces the parametrized isResult test (which asserted the same,
now-wrong, behavior for both types) with one for task_error, and adds
two for the root "error" type on a running and a waiting task.

* docs(web): name the code set and the fixture's two producers in comments

task_orchestrator.py: the elif branch for ConnectorRuntimeError covers
more codes than the three missing-value ones (invalid_runtime_context,
connector_runtime_unavailable, and the *_resolution_failed codes also
land here), so "the three codes" needs a referent to not read as the
whole branch.

app-context-chat.test.tsx: the waiting-rejection fixture combines
`task` from the resume-refusal path (websocket.py:8935) with
`error_code` from the pause-refusal path (websocket.py:8491). Neither
path emits both today; note the synthesis so it doesn't read as one
producer emitting both fields.

* refactor(frontend): derive the error frame's display values in one function

These 85 lines derive five display values from one frame, and the four
conditions each value needs are today recomputed in place with no shared
decision point. Two review rounds have found defects in this handler: R1
found isResult mixed across channels and a keyed bubble; R2 found an
untrusted-transport wording gap, a wrong dedup identity axis, and an
unwitnessed no-version path. Turning the matrix into one function's explicit
return value is the precondition for the next defect being visible instead
of re-derived and missed again.

Pure on purpose: no dispatch, no refs, nothing outside its arguments, so
every cell of the matrix is unit-testable without rendering the provider --
the same shape extractTaskControlEnvelope above already uses.

Verified equivalent, not just typed the same: a temporary shim in the test
file, built by copying the case block's five expressions verbatim, pinned
six cells' expected values before this extraction touched any production
code (155 passed). After the extraction, the same six cells against the
real function produce the same 155 passed with the same expected values.
The shim is not part of this commit.

* fix(frontend): dedup terminal errors by the frame's own state version

The 30-second dedup keys on the server sentence, and one sentence covers a
whole code -- so two turns failing under the same code shared a key while
being two distinct failures, and that bubble is now the turn's result.
Keying on the failure's class instead of on the occurrence cannot tell them
apart, whichever class you pick: the sentence, the code, or the code and
reason together.

What identifies the occurrence is already on the frame. broadcast_to_task
stamps every frame of this type with the row's run_id and state_version
(task_error is in _VERSIONED_TASK_EVENT_TYPES), and state_version is bumped
by each control transition that changes (status, control_state) -- a retry
takes the lease FAILED -> RUNNING and settles RUNNING -> FAILED, so the
second failure is at least two versions on, while one settlement broadcast
twice carries one version. The identity is therefore run_id:state_version,
read from the envelope the handler already parses before the switch. No wire
field is added, no backend line changes, and the earlier (code, reason)
identity is removed rather than kept alongside it.

A frame that arrives with no version gets no identity and keys on the text
alone, which is the behaviour that predates this change: the version gate at
the top of the handler drops such a frame once any versioned event has been
seen for the task, and when the task has no versioned event on record
either, two such frames key on the same text and the second still collapses.
The identity is withheld there rather than guessed -- attaching the state
tuple needs the row, and a settled FAILED task has one.

* fix(frontend): localize connector runtime codes through the client error table

The frame's code, not the relayed sentence, decides the bubble's wording now:
the five connector-runtime codes that can reach this frame are listed in the
client error-code table this repository already uses for the root error
channel's error_code field, each with a translation key and an English
fallback. That table is the reason the wording now survives an untrusted
transport, where relaying server prose is refused by design (xorbitsai#1938) -- before
this, only three codes had curated wording and every other code, connector
codes included, read "Unknown error" for an anonymous widget or share-link
visitor.

Only codes with a producer that can reach this frame today are listed, the
same rule the server's reason whitelist already states about itself. Of the
other five connector-runtime codes, two have no raise site in this repository
at all. The remaining three are raised while a connector-runtime payload is
being validated. Nothing that reaches those checks settles a task: a request
handler answers the call with an error response (the /v1 task endpoints, and
the trigger-config endpoints, which convert the failure into their own
service error), and the trigger run-preparation path throws before the task
row is created and records the failure on its TriggerRun row. No settled task
means no terminal frame. A code the table does not list keeps the generic
prefixed wording.

This also fixes: the logged-in audience no longer sees a different wording
than an untrusted transport gets for connector_runtime_unavailable, since
that code now has its own table entry instead of relaying the server's four
different English sentences for it. The old single-purpose
CONNECTOR_RUNTIME_MISSING_VALUE_CODES set and its one i18n key are gone.

* fix(web): type-gate the code before the closed-set membership test

N1: the code passed into create_terminal_task_error_event is typed as a bare
str but not enforced at runtime -- ConnectorRuntimeError itself types its
code the same way and stores it unvalidated. An unhashable value (a list)
would raise inside the frozenset membership test on a path whose whole point
is that it never raises; a hashable non-string is simply not a member and
already takes the drop path either way. The type check now comes first, the
same way the neighboring `details` check already does.

N3: the operator log line in task_orchestrator reads the same
ConnectorRuntimeError.details attribute the wire projector does, for a
different reason (an operator gets the raw connector identity; the wire
projector filters it out) -- but it read it without the same shape guard.
Guarded now with the same isinstance check before reading, at the cost of
three lines. The non-dict branch this adds has no test witness in this
repository today: triggering it needs a `details` attribute reassigned to a
non-dict shape after construction, a shape no raise site produces, and the
existing log-line test only covers the dict-shaped path continuing to log
correctly. The follow-up (a test that exercises the non-dict branch) is not
done in this PR, per approved scope.

S1: dropped the @lru_cache on _client_visible_error_codes -- a ~30-member
frozenset is cheaper to rebuild per call than to reason about as a cache. The
deferred import and its circular-dependency comment are unchanged.

S2: deleted the ownership-boundary test for PublicErrorDetails's single
construction site; the wire-safety guarantee it was adjacent to
(`type(details) is not PublicErrorDetails` in the frame builder) is
untouched. `_python_sources` stays -- the reason-whitelist derivation still
uses it.

The reason-whitelist AST machinery is kept rather than flattened to a
hardcoded list -- it exists to catch a future raise site shaped like today's
interpolated ones, which a list can't -- and gets three more assertions: the
three regex-admitted `undeclared_*_key` reasons are now derived from the
same section-name constants the raise site loops over, rather than only
regex-matched (this surfaced a second interpolated pattern the scanner
finds, `missing_context.<key>`, which is deliberately ungrounded -- it is
assembled from a connector owner's declared key name and is already asserted
elsewhere to never reach the wire); the two blind spots in the scanner
itself (a `details=` argument that isn't a literal dict, and a construction
reached through an attribute rather than a bare name) are each pinned to
their real occupancy today (one non-literal site, zero attribute
constructions); and the five opaque reason expressions this repository
raises are pinned to the one shape they take (`str(exc)`), so a future
%-format or .format() shows up as a failure here instead of silently
widening what the whitelist has to cover.

Also added: a parametrized test pinning three non-string code shapes through
the type gate, and a frontend test for the code/details-nested-under-data
half of the existing root/data fallback.

* docs(frontend): fix three positional comment references broken by the C1/C2 moves

All three comments were carried over verbatim from where the code used to
live and pointed at a fixed position ("above", "below") rather than at what
they meant. Moving the code left the words in place while the thing they
pointed at moved elsewhere in the file, or left a position reference that
still landed on something true but not on what the sentence meant:

- The isResult comment in projectErrorFrameForDisplay said "see ADD_MESSAGE
  above", written when this code lived inside the case block far below the
  ADD_MESSAGE reducer case. The extraction moved it above that reducer case,
  so "above" now points at nothing there; named the reducer case directly
  instead of relying on file position.
- The six-cell test's controlEnvelope comment said "the no-version cell
  below", but that cell is the array literal above the it.each callback the
  comment sits in, not below it.
- The dedup-identity comment said "the version gate above", meaning the
  version gate's call site in the handler (described elsewhere in this PR as
  "the version gate at the top of the handler", which sits well below this
  comment, not above it). "Above" happened to still land on something true --
  canAcceptTaskControlVersion's definition is above -- but not on what the
  sentence meant; named the function directly instead of relying on
  position.

* fix(web): whitelist the access-resolution reason main now raises

main's xorbitsai#1912 added resolve_connector_access_or_raise() to
connector_team_scope.py, whose generic-exception fallback arm raises
ConnectorRuntimeError with reason "connector_access_resolution_failed".
This PR's whitelist-coverage test requires every reason a
ConnectorRuntimeError construction can produce to be classified in the
same change that adds the raise site, so merging main's new raise site
into this branch turned that test red until the reason is classified
here.

Judged against the two questions this whitelist's comment asks: does
the reason name who owns the task, or state what an authorization
check concluded? Neither. It only reports that resolving connector
access failed, the same as the already-whitelisted
team_scope_resolution_failed a few lines above -- same module, same
503, same fallback-arm shape.

* docs(frontend): anchor two websocket producer references by symbol, not line

Merging main shifted websocket.py's line numbers; replaced the two stale
websocket.py:<line> citations with the hosting function's name, matching
this PR's existing convention of naming the referent instead of relying
on a position that moves.

* refactor(web): send only the error code on terminal task_error frames

The terminal task_error frame no longer carries a details object. The
server used to project a connector-runtime failure onto a (code,
reason) pair, filtering reason through a fixed allowlist before it
reached broadcast_to_task -- whose audience includes anonymous widget
and share-link visitors. That allowlist has no consumer today: the
client only ever read the code, so the whole reason channel was dead
weight carrying review risk with nothing on the other end.

create_terminal_task_error_event now takes only a code argument.
PublicErrorDetails, the reason allowlist, and the two-value projector
are gone; connector_runtime_client_code replaces them with a
single-purpose projection from exception to code. The frontend
projection follows: TaskErrorProjection carries only code, and
getTaskErrorProjection no longer reads a details field.

* fix(web): scope the terminal-frame code closed set to connector-runtime codes

The terminal task_error frame validated its code argument against
V1ErrorCode, the repository's full /v1 error surface -- roughly thirty
codes covering everything from rate limiting to workforce archival,
most of which have nothing to do with a connector runtime. The frame
reaches anonymous widget and share-link visitors, so its own closed
set should describe exactly what belongs there, not borrow a much
wider one that happens to be a superset.

CONNECTOR_RUNTIME_CLIENT_ERROR_CODES replaces the V1ErrorCode lookup
with the eight connector-runtime codes this repository actually
raises as a ConnectorRuntimeError. The two authorization-outcome codes
(mcp_oauth_authorization_failed, delegated_authorization_failed) stay
out: nothing raises them today, and each one states the outcome of an
authorization check, which this frame must never carry.

* test(web): pin where terminal-frame code arguments come from

create_terminal_task_error_event's own runtime gate only checks the
value of a code argument -- whether it is a member of the closed set
-- and has no way to tell a curated projection from an incidental
string that happens to collide with a real code today. A future call
site could pass str(exc) or read .code straight off an exception and
this repository would not notice until the wrong fact reached an
anonymous visitor.

This AST-based test closes that gap statically: every call site under
web/ that passes code= must bind it, in the same function, from a
direct call to connector_runtime_client_code -- the one projector this
repository trusts for this purpose. Today that is exactly one call
site, task_orchestrator.py's _runner.

* test(frontend): cover the resume-settlement task_error frame and the trace drain

Two terminal task_error producers carry no code today, and neither had
coverage: external_task_cancel.py's cancellation broadcast (message
only) and websocket.py's resume-settlement broadcast, which carries
error_code on the root instead. Both still have to make the frame the
turn's result on isTerminal alone, and the cancellation path also has
to carry forward whatever trace events accumulated on state.traceEvents
before the settlement -- the one place that happens, in ADD_MESSAGE's
isResult branch.

Adds a resume-settlement case to the projectErrorFrameForDisplay table
and a transport-level test that seeds two trace events, delivers a
cancellation frame, and checks they land on the settling message while
state.traceEvents is cleared. Also pins client-errors.ts's fallback
strings against the English locale so the two tables cannot drift.

* refactor(web): drop the unused fallback parameter

connector_runtime_client_message's fallback parameter has had exactly
one caller since it was added, and that caller never overrides the
default. The two return sites use CLIENT_SAFE_TASK_FAILURE directly
now; required_mcp_unavailable_client_message keeps its own fallback
parameter, which does have an overriding caller.

* docs: describe protected invariants without review-round references

Sweeps the batch for review-round references and stale comments the
earlier commits in this sequence left behind. The regex sweep itself
found nothing, but a manual read turned up three comments describing
a mechanism the batch already removed: one docstring still claimed
code was checked against "the same closed set the /v1 surface pins
against" after the closed set became a curated subset, and two
fixture comments in test_task_orchestrator.py still described a
reason value as "public" or "withheld" from the wire after this
batch's first commit removed the wire's reason channel entirely.

* test(web): name the closed-set producer guard for what it pins and drop a duplicate frame pin
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants